Compare commits
10 Commits
3a8f132b91
...
77789d31ca
| Author | SHA1 | Date | |
|---|---|---|---|
| 77789d31ca | |||
| af5c855c21 | |||
| 4e8feba5fe | |||
| 1d8bcdd626 | |||
| 6f25507922 | |||
| 051e1e8a32 | |||
| abffb52af1 | |||
| 78d4a10f33 | |||
| b32f47c2b5 | |||
| b6cb3059af |
@@ -142,3 +142,34 @@ Point your domain (e.g., `gym.yourdomain.com`) to the NAS IP and the mapped port
|
||||
- Forward Hostname / IP: `[NAS_IP]`
|
||||
- Forward Port: `3033`
|
||||
- Websockets Support: Enable (if needed for future features).
|
||||
|
||||
## 6. Troubleshooting
|
||||
|
||||
### "Readonly Database" Error
|
||||
If you see an error like `Invalid prisma.userProfile.upsert() invocation: attempt to write a readonly database`:
|
||||
|
||||
1. **Verify Permissions:** Run the diagnostic script inside your container:
|
||||
```bash
|
||||
docker exec -it node-apps node /usr/src/app/gymflow/server/check_db_perms.js
|
||||
```
|
||||
2. **Fix Permissions:** If the checks fail, run these commands on your NAS inside the `gymflow/server` directory:
|
||||
```bash
|
||||
sudo chmod 777 .
|
||||
sudo chmod 666 prod.db
|
||||
```
|
||||
*Note: SQLite needs write access to the directory itself to create temporary journaling files (`-wal`, `-shm`).*
|
||||
|
||||
3. **Check Docker User:** Alternatively, ensure your Docker container is running as a user who owns these files (e.g., set `user: "1000:1000"` in `docker-compose.yml` if your NAS user has that ID).
|
||||
|
||||
### "Invalid ELF Header" Error
|
||||
If you see an error like `invalid ELF header` for `better-sqlite3.node`:
|
||||
This happens because the `node_modules` contains Windows binaries (from your local machine) instead of Linux binaries.
|
||||
|
||||
1. **Fix Inside Container:** Run the following command to force a rebuild of native modules for Linux:
|
||||
```bash
|
||||
docker exec -it node-apps /bin/sh -c "cd /usr/src/app/gymflow/server && npm rebuild better-sqlite3"
|
||||
```
|
||||
2. **Restart Container:** After rebuilding, restart the container:
|
||||
```bash
|
||||
docker-compose restart nodejs-apps
|
||||
```
|
||||
|
||||
File diff suppressed because one or more lines are too long
29
server/check_adapter_props.js
Normal file
29
server/check_adapter_props.js
Normal file
@@ -0,0 +1,29 @@
|
||||
const { PrismaBetterSqlite3 } = require('@prisma/adapter-better-sqlite3');
|
||||
const path = require('path');
|
||||
|
||||
async function check() {
|
||||
console.log('--- Prisma Adapter Diagnostic ---');
|
||||
const factory = new PrismaBetterSqlite3({ url: 'file:./dev.db' });
|
||||
|
||||
console.log('Factory Properties:');
|
||||
console.log(Object.keys(factory));
|
||||
console.log('Factory.adapterName:', factory.adapterName);
|
||||
console.log('Factory.provider:', factory.provider);
|
||||
|
||||
try {
|
||||
const adapter = await factory.connect();
|
||||
console.log('\nAdapter Properties:');
|
||||
console.log(Object.keys(adapter));
|
||||
console.log('Adapter name:', adapter.adapterName);
|
||||
console.log('Adapter provider:', adapter.provider);
|
||||
|
||||
// Also check if there are hidden/prototype properties
|
||||
let proto = Object.getPrototypeOf(adapter);
|
||||
console.log('Adapter Prototype Properties:', Object.getOwnPropertyNames(proto));
|
||||
|
||||
} catch (e) {
|
||||
console.error('Failed to connect:', e);
|
||||
}
|
||||
}
|
||||
|
||||
check();
|
||||
45
server/check_db_perms.js
Normal file
45
server/check_db_perms.js
Normal file
@@ -0,0 +1,45 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const dbPath = path.resolve(__dirname, 'prod.db');
|
||||
const dirPath = __dirname;
|
||||
|
||||
console.log('--- GymFlow Database Permission Check ---');
|
||||
console.log(`Checking directory: ${dirPath}`);
|
||||
console.log(`Checking file: ${dbPath}`);
|
||||
|
||||
// 1. Check Directory
|
||||
try {
|
||||
fs.accessSync(dirPath, fs.constants.R_OK | fs.constants.W_OK);
|
||||
console.log('✅ Directory is readable and writable.');
|
||||
} catch (err) {
|
||||
console.error('❌ Directory is NOT writable! SQLite needs directory write access to create temporary files.');
|
||||
}
|
||||
|
||||
// 2. Check File
|
||||
if (fs.existsSync(dbPath)) {
|
||||
try {
|
||||
fs.accessSync(dbPath, fs.constants.R_OK | fs.constants.W_OK);
|
||||
console.log('✅ Database file is readable and writable.');
|
||||
} catch (err) {
|
||||
console.error('❌ Database file is NOT writable!');
|
||||
}
|
||||
} else {
|
||||
console.log('ℹ️ Database file does not exist yet.');
|
||||
}
|
||||
|
||||
// 3. Try to write a test file in the directory
|
||||
const testFile = path.join(dirPath, '.write_test');
|
||||
try {
|
||||
fs.writeFileSync(testFile, 'test');
|
||||
fs.unlinkSync(testFile);
|
||||
console.log('✅ Successfully performed a test write in the directory.');
|
||||
} catch (err) {
|
||||
console.error(`❌ Failed test write in directory: ${err.message}`);
|
||||
}
|
||||
|
||||
console.log('\n--- Recommendation ---');
|
||||
console.log('If any checks failed, run these commands on your NAS (in the gymflow/server folder):');
|
||||
console.log('1. sudo chmod 777 .');
|
||||
console.log('2. sudo chmod 666 prod.db');
|
||||
console.log('\nAlternatively, ensure your Docker container is running with a user that owns these files.');
|
||||
@@ -1,43 +1,43 @@
|
||||
name,type,bodyWeightPercentage,isUnilateral
|
||||
Air Squats,BODYWEIGHT,1.0,false
|
||||
Barbell Row,STRENGTH,0,false
|
||||
Bench Press,STRENGTH,0,false
|
||||
Bicep Curl,STRENGTH,0,true
|
||||
Bulgarian Split-Squat Jumps,BODYWEIGHT,1.0,true
|
||||
Bulgarian Split-Squats,BODYWEIGHT,1.0,true
|
||||
Burpees,BODYWEIGHT,1.0,false
|
||||
Calf Raise,STRENGTH,0,true
|
||||
Chin-Ups,BODYWEIGHT,1.0,false
|
||||
Cycling,CARDIO,0,false
|
||||
Deadlift,STRENGTH,0,false
|
||||
Dips,BODYWEIGHT,1.0,false
|
||||
Dumbbell Curl,STRENGTH,0,true
|
||||
Dumbbell Shoulder Press,STRENGTH,0,true
|
||||
Face Pull,STRENGTH,0,false
|
||||
Front Squat,STRENGTH,0,false
|
||||
Hammer Curl,STRENGTH,0,true
|
||||
Handstand,BODYWEIGHT,1.0,false
|
||||
Hip Thrust,STRENGTH,0,false
|
||||
Jump Rope,CARDIO,0,false
|
||||
Lat Pulldown,STRENGTH,0,false
|
||||
Leg Extension,STRENGTH,0,true
|
||||
Leg Press,STRENGTH,0,false
|
||||
Lunges,BODYWEIGHT,1.0,true
|
||||
Mountain Climbers,CARDIO,0,false
|
||||
Muscle-Up,BODYWEIGHT,1.0,false
|
||||
Overhead Press,STRENGTH,0,false
|
||||
Plank,STATIC,0,false
|
||||
Pull-Ups,BODYWEIGHT,1.0,false
|
||||
Push-Ups,BODYWEIGHT,0.65,false
|
||||
Romanian Deadlift,STRENGTH,0,false
|
||||
Rowing,CARDIO,0,false
|
||||
Running,CARDIO,0,false
|
||||
Russian Twist,BODYWEIGHT,0,false
|
||||
Seated Cable Row,STRENGTH,0,false
|
||||
Side Plank,STATIC,0,true
|
||||
Sissy Squats,BODYWEIGHT,1.0,false
|
||||
Sprint,CARDIO,0,false
|
||||
Squat,STRENGTH,0,false
|
||||
Treadmill,CARDIO,0,false
|
||||
Tricep Extension,STRENGTH,0,false
|
||||
Wall-Sit,STATIC,0,false
|
||||
name,name_ru,type,bodyWeightPercentage,isUnilateral
|
||||
Air Squats,Приседания,BODYWEIGHT,1.0,false
|
||||
Barbell Row,Тяга штанги в наклоне,STRENGTH,0,false
|
||||
Bench Press,Жим лежа,STRENGTH,0,false
|
||||
Bicep Curl,Подъем на бицепс,STRENGTH,0,true
|
||||
Bulgarian Split-Squat Jumps,Болгарские сплит-прыжки,BODYWEIGHT,1.0,true
|
||||
Bulgarian Split-Squats,Болгарские сплит-приседания,BODYWEIGHT,1.0,true
|
||||
Burpees,Берпи,BODYWEIGHT,1.0,false
|
||||
Calf Raise,Подъем на носки,STRENGTH,0,true
|
||||
Chin-Ups,Подтягивания обратным хватом,BODYWEIGHT,1.0,false
|
||||
Cycling,Велосипед,CARDIO,0,false
|
||||
Deadlift,Становая тяга,STRENGTH,0,false
|
||||
Dips,Отжимания на брусьях,BODYWEIGHT,1.0,false
|
||||
Dumbbell Curl,Сгибания рук с гантелями,STRENGTH,0,true
|
||||
Dumbbell Shoulder Press,Жим гантелей сидя,STRENGTH,0,true
|
||||
Face Pull,Тяга к лицу,STRENGTH,0,false
|
||||
Front Squat,Фронтальный присед,STRENGTH,0,false
|
||||
Hammer Curl,Сгибания "Молот",STRENGTH,0,true
|
||||
Handstand,Стойка на руках,BODYWEIGHT,1.0,false
|
||||
Hip Thrust,Ягодичный мостик,STRENGTH,0,false
|
||||
Jump Rope,Скакалка,CARDIO,0,false
|
||||
Lat Pulldown,Тяга верхнего блока,STRENGTH,0,false
|
||||
Leg Extension,Разгибание ног в тренажере,STRENGTH,0,true
|
||||
Leg Press,Жим ногами,STRENGTH,0,false
|
||||
Lunges,Выпады,BODYWEIGHT,1.0,true
|
||||
Mountain Climbers,Альпинист,CARDIO,0,false
|
||||
Muscle-Up,Выход силой,BODYWEIGHT,1.0,false
|
||||
Overhead Press,Армейский жим,STRENGTH,0,false
|
||||
Plank,Планка,STATIC,0,false
|
||||
Pull-Ups,Подтягивания,BODYWEIGHT,1.0,false
|
||||
Push-Ups,Отжимания,BODYWEIGHT,0.65,false
|
||||
Romanian Deadlift,Румынская тяга,STRENGTH,0,false
|
||||
Rowing,Гребля,CARDIO,0,false
|
||||
Running,Бег,CARDIO,0,false
|
||||
Russian Twist,Русский твист,BODYWEIGHT,0,false
|
||||
Seated Cable Row,Тяга блока к поясу,STRENGTH,0,false
|
||||
Side Plank,Боковая планка,STATIC,0,true
|
||||
Sissy Squats,Сисси-приседания,BODYWEIGHT,1.0,false
|
||||
Sprint,Спринт,CARDIO,0,false
|
||||
Squat,Приседания со штангой,STRENGTH,0,false
|
||||
Treadmill,Беговая дорожка,CARDIO,0,false
|
||||
Tricep Extension,Разгибание рук на трицепс,STRENGTH,0,false
|
||||
Wall-Sit,Стульчик у стены,STATIC,0,false
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 18 and column 30.
|
Binary file not shown.
Binary file not shown.
BIN
server/prod.db
BIN
server/prod.db
Binary file not shown.
@@ -54,10 +54,33 @@ async function resetDb() {
|
||||
// 4. Create the Admin user
|
||||
console.log(`Creating fresh admin user...`);
|
||||
|
||||
// In Prisma 7, we must use the adapter for better-sqlite3
|
||||
// In Prisma 7, PrismaBetterSqlite3 is a factory.
|
||||
// We use the factory to create the adapter, then we access the internal client
|
||||
// to disable WAL mode for NAS/Network share compatibility (journal_mode = DELETE).
|
||||
const { PrismaBetterSqlite3 } = require('@prisma/adapter-better-sqlite3');
|
||||
const adapter = new PrismaBetterSqlite3({ url: dbPath });
|
||||
const prisma = new PrismaClient({ adapter });
|
||||
const factory = new PrismaBetterSqlite3({ url: dbPath });
|
||||
|
||||
const adapterWrapper = {
|
||||
provider: 'sqlite',
|
||||
adapterName: '@prisma/adapter-better-sqlite3',
|
||||
async connect() {
|
||||
const adapter = await factory.connect();
|
||||
if (adapter.client) {
|
||||
console.log(`Setting journal_mode = DELETE for NAS compatibility`);
|
||||
adapter.client.pragma('journal_mode = DELETE');
|
||||
}
|
||||
return adapter;
|
||||
},
|
||||
async connectToShadowDb() {
|
||||
const adapter = await factory.connectToShadowDb();
|
||||
if (adapter.client) {
|
||||
adapter.client.pragma('journal_mode = DELETE');
|
||||
}
|
||||
return adapter;
|
||||
}
|
||||
};
|
||||
|
||||
const prisma = new PrismaClient({ adapter: adapterWrapper });
|
||||
|
||||
try {
|
||||
const hashedPassword = await bcrypt.hash(adminPassword, 10);
|
||||
|
||||
@@ -83,6 +83,21 @@ export class AuthController {
|
||||
}
|
||||
}
|
||||
|
||||
static async initializeAccount(req: any, res: Response) {
|
||||
try {
|
||||
const userId = req.user.userId;
|
||||
const { language, birthDate, height, weight, gender } = req.body;
|
||||
if (!language) {
|
||||
return sendError(res, 'Language is required', 400);
|
||||
}
|
||||
const user = await AuthService.initializeUser(userId, language, { birthDate, height, weight, gender });
|
||||
return sendSuccess(res, { user });
|
||||
} catch (error: any) {
|
||||
logger.error('Error in initializeAccount', { error });
|
||||
return sendError(res, error.message || 'Server error', 500);
|
||||
}
|
||||
}
|
||||
|
||||
static async getAllUsers(req: any, res: Response) {
|
||||
try {
|
||||
if (req.user.role !== 'ADMIN') {
|
||||
|
||||
@@ -7,7 +7,8 @@ export class ExerciseController {
|
||||
static async getAllExercises(req: any, res: Response) {
|
||||
try {
|
||||
const userId = req.user.userId;
|
||||
const exercises = await ExerciseService.getAllExercises(userId);
|
||||
const includeArchived = req.query.includeArchived === 'true';
|
||||
const exercises = await ExerciseService.getAllExercises(userId, includeArchived);
|
||||
return sendSuccess(res, exercises);
|
||||
} catch (error) {
|
||||
logger.error('Error in getAllExercises', { error });
|
||||
|
||||
@@ -65,7 +65,7 @@ async function ensureAdminUser() {
|
||||
console.info(`ℹ️ Admin user exists but with different email: ${existingAdmin.email}. Expected: ${adminEmail}`);
|
||||
}
|
||||
// Even if admin exists, ensure exercises are seeded (will skip if already has them)
|
||||
await AuthService.seedDefaultExercises(existingAdmin.id);
|
||||
await AuthService.seedDefaultExercises(existingAdmin.id, 'en');
|
||||
await prisma.$disconnect();
|
||||
return;
|
||||
}
|
||||
@@ -77,12 +77,12 @@ async function ensureAdminUser() {
|
||||
email: adminEmail,
|
||||
password: hashed,
|
||||
role: 'ADMIN',
|
||||
profile: { create: { weight: 70 } },
|
||||
profile: { create: { weight: 70, language: 'en' } },
|
||||
},
|
||||
});
|
||||
|
||||
// Seed exercises for new admin
|
||||
await AuthService.seedDefaultExercises(admin.id);
|
||||
await AuthService.seedDefaultExercises(admin.id, 'en');
|
||||
|
||||
console.info(`✅ Admin user created and exercises seeded (email: ${adminEmail})`);
|
||||
await prisma.$disconnect();
|
||||
|
||||
@@ -35,13 +35,36 @@ console.log('Initializing Prisma Client with database:', dbPath);
|
||||
|
||||
let prisma: PrismaClient;
|
||||
|
||||
// In Prisma 7, PrismaBetterSqlite3 is a factory.
|
||||
// We use a wrapper to intercept the connection and disable WAL mode
|
||||
// for NAS/Network share compatibility (journal_mode = DELETE).
|
||||
try {
|
||||
const adapter = new PrismaBetterSqlite3({ url: dbPath });
|
||||
const factory = new PrismaBetterSqlite3({ url: dbPath });
|
||||
|
||||
const adapterWrapper = {
|
||||
provider: 'sqlite',
|
||||
adapterName: '@prisma/adapter-better-sqlite3',
|
||||
async connect() {
|
||||
const adapter = (await factory.connect()) as any;
|
||||
if (adapter.client) {
|
||||
console.log('[Prisma] Setting journal_mode = DELETE for NAS compatibility');
|
||||
adapter.client.pragma('journal_mode = DELETE');
|
||||
}
|
||||
return adapter;
|
||||
},
|
||||
async connectToShadowDb() {
|
||||
const adapter = (await factory.connectToShadowDb()) as any;
|
||||
if (adapter.client) {
|
||||
adapter.client.pragma('journal_mode = DELETE');
|
||||
}
|
||||
return adapter;
|
||||
}
|
||||
};
|
||||
|
||||
prisma =
|
||||
global.prisma ||
|
||||
new PrismaClient({
|
||||
adapter: adapter as any,
|
||||
adapter: adapterWrapper as any,
|
||||
});
|
||||
} catch (e: any) {
|
||||
console.error('Failed to initialize Prisma Client:', e.message);
|
||||
|
||||
@@ -16,6 +16,7 @@ router.use(authenticateToken); // Standard middleware now
|
||||
router.get('/me', AuthController.getCurrentUser);
|
||||
router.post('/change-password', validate(changePasswordSchema), AuthController.changePassword);
|
||||
router.patch('/profile', validate(updateProfileSchema), AuthController.updateProfile);
|
||||
router.post('/initialize', AuthController.initializeAccount);
|
||||
|
||||
// Admin routes
|
||||
router.get('/users', AuthController.getAllUsers);
|
||||
|
||||
@@ -67,17 +67,13 @@ export class AuthService {
|
||||
include: { profile: true }
|
||||
});
|
||||
|
||||
// Seed default exercises
|
||||
// Seed default exercises
|
||||
await this.seedDefaultExercises(user.id);
|
||||
|
||||
const token = jwt.sign({ userId: user.id, role: user.role }, JWT_SECRET);
|
||||
const { password: _, ...userSafe } = user;
|
||||
|
||||
return { user: userSafe, token };
|
||||
}
|
||||
|
||||
static async seedDefaultExercises(userId: string) {
|
||||
static async seedDefaultExercises(userId: string, language: string = 'en') {
|
||||
try {
|
||||
// Ensure env is loaded from root (in case server didn't restart)
|
||||
if (!process.env.DEFAULT_EXERCISES_CSV_PATH) {
|
||||
@@ -110,6 +106,8 @@ export class AuthService {
|
||||
const headers = lines[0].split(',').map(h => h.trim());
|
||||
const exercisesToCreate = [];
|
||||
|
||||
const nameColumn = language === 'ru' ? 'name_ru' : 'name';
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const cols = lines[i].split(',').map(c => c.trim());
|
||||
if (cols.length < headers.length) continue;
|
||||
@@ -117,10 +115,12 @@ export class AuthService {
|
||||
const row: any = {};
|
||||
headers.forEach((h, idx) => row[h] = cols[idx]);
|
||||
|
||||
if (row.name && row.type) {
|
||||
const exerciseName = row[nameColumn] || row['name'];
|
||||
|
||||
if (exerciseName && row.type) {
|
||||
exercisesToCreate.push({
|
||||
userId,
|
||||
name: row.name,
|
||||
name: exerciseName,
|
||||
type: row.type,
|
||||
bodyWeightPercentage: row.bodyWeightPercentage ? parseFloat(row.bodyWeightPercentage) : 0,
|
||||
isUnilateral: row.isUnilateral === 'true',
|
||||
@@ -153,14 +153,47 @@ export class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
static async initializeUser(userId: string, language: string, profileData: any = {}) {
|
||||
// Prepare profile update data
|
||||
const updateData: any = { language };
|
||||
|
||||
if (profileData.weight && !isNaN(parseFloat(profileData.weight))) updateData.weight = parseFloat(profileData.weight);
|
||||
if (profileData.height && !isNaN(parseFloat(profileData.height))) updateData.height = parseFloat(profileData.height);
|
||||
if (profileData.gender) updateData.gender = profileData.gender;
|
||||
if (profileData.birthDate && profileData.birthDate !== '') {
|
||||
const date = new Date(profileData.birthDate);
|
||||
if (!isNaN(date.getTime())) {
|
||||
updateData.birthDate = date;
|
||||
}
|
||||
}
|
||||
|
||||
// Update profile language and other attributes
|
||||
await prisma.userProfile.upsert({
|
||||
where: { userId },
|
||||
update: updateData,
|
||||
create: { userId, ...updateData }
|
||||
});
|
||||
|
||||
// Seed exercises in that language
|
||||
await this.seedDefaultExercises(userId, language);
|
||||
|
||||
// Mark as first login done
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { isFirstLogin: false }
|
||||
});
|
||||
|
||||
// Return updated user
|
||||
return this.getUser(userId);
|
||||
}
|
||||
|
||||
static async changePassword(userId: string, newPassword: string) {
|
||||
const hashed = await bcrypt.hash(newPassword, 10);
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
password: hashed,
|
||||
isFirstLogin: false
|
||||
password: hashed
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import prisma from '../lib/prisma';
|
||||
|
||||
export class ExerciseService {
|
||||
static async getAllExercises(userId: string) {
|
||||
static async getAllExercises(userId: string, includeArchived: boolean = false) {
|
||||
const exercises = await prisma.exercise.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ userId: null }, // System default
|
||||
{ userId } // User custom
|
||||
AND: [
|
||||
{
|
||||
OR: [
|
||||
{ userId: null }, // System default
|
||||
{ userId } // User custom
|
||||
]
|
||||
},
|
||||
includeArchived ? {} : { isArchived: false }
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
16
src/App.tsx
16
src/App.tsx
@@ -8,6 +8,7 @@ import AICoach from './components/AICoach';
|
||||
import Plans from './components/Plans';
|
||||
import Login from './components/Login';
|
||||
import Profile from './components/Profile';
|
||||
import InitializeAccount from './components/InitializeAccount';
|
||||
import { Language, User } from './types';
|
||||
import { getSystemLanguage } from './services/i18n';
|
||||
import { useAuth } from './context/AuthContext';
|
||||
@@ -49,6 +50,10 @@ function App() {
|
||||
return <Navigate to="/login" />;
|
||||
}
|
||||
|
||||
if (currentUser?.isFirstLogin && location.pathname !== '/initialize') {
|
||||
return <Navigate to="/initialize" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-[100dvh] w-screen bg-surface text-on-surface font-sans flex flex-col md:flex-row overflow-hidden">
|
||||
{currentUser && (
|
||||
@@ -66,6 +71,17 @@ function App() {
|
||||
<Navigate to="/" />
|
||||
)
|
||||
} />
|
||||
<Route path="/initialize" element={
|
||||
currentUser && currentUser.isFirstLogin ? (
|
||||
<InitializeAccount
|
||||
onInitialized={updateUser}
|
||||
language={language}
|
||||
onLanguageChange={setLanguage}
|
||||
/>
|
||||
) : (
|
||||
<Navigate to="/" />
|
||||
)
|
||||
} />
|
||||
<Route path="/" element={
|
||||
<Tracker lang={language} />
|
||||
} />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useId } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { X, Eye, EyeOff } from 'lucide-react';
|
||||
|
||||
interface FilledInputProps {
|
||||
label: string;
|
||||
@@ -18,15 +18,19 @@ interface FilledInputProps {
|
||||
rightElement?: React.ReactNode;
|
||||
multiline?: boolean;
|
||||
rows?: number;
|
||||
showPasswordToggle?: boolean;
|
||||
}
|
||||
|
||||
const FilledInput: React.FC<FilledInputProps> = ({
|
||||
label, value, onChange, onClear, onFocus, onBlur, type = "number", icon,
|
||||
autoFocus, step, inputMode, autocapitalize, autoComplete, rightElement,
|
||||
multiline = false, rows = 3
|
||||
multiline = false, rows = 3, showPasswordToggle = false
|
||||
}) => {
|
||||
const id = useId();
|
||||
const inputRef = React.useRef<HTMLInputElement | HTMLTextAreaElement>(null);
|
||||
const [showPassword, setShowPassword] = React.useState(false);
|
||||
|
||||
const actualType = type === 'password' && showPassword ? 'text' : type;
|
||||
|
||||
const handleClear = () => {
|
||||
const syntheticEvent = {
|
||||
@@ -47,11 +51,11 @@ const FilledInput: React.FC<FilledInputProps> = ({
|
||||
<input
|
||||
ref={inputRef as React.RefObject<HTMLInputElement>}
|
||||
id={id}
|
||||
type={type}
|
||||
type={actualType}
|
||||
step={step}
|
||||
inputMode={inputMode || (type === 'number' ? 'decimal' : 'text')}
|
||||
autoFocus={autoFocus}
|
||||
className={`w-full h-[56px] pt-5 pb-1 pl-4 bg-transparent text-body-lg text-on-surface focus:outline-none placeholder-transparent ${rightElement ? 'pr-20' : 'pr-10'}`}
|
||||
className={`w-full h-[56px] pt-5 pb-1 pl-4 bg-transparent text-body-lg text-on-surface focus:outline-none placeholder-transparent ${rightElement ? 'pr-20' : (showPasswordToggle && type === 'password' ? 'pr-20' : 'pr-10')}`}
|
||||
placeholder=" "
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
@@ -80,12 +84,24 @@ const FilledInput: React.FC<FilledInputProps> = ({
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
aria-label="Clear input"
|
||||
className={`absolute top-1/2 -translate-y-1/2 p-2 text-on-surface-variant hover:text-on-surface rounded-full transition-opacity ${rightElement ? 'right-12' : 'right-2'}`}
|
||||
className={`absolute top-1/2 -translate-y-1/2 p-2 text-on-surface-variant hover:text-on-surface rounded-full transition-opacity ${(rightElement || (showPasswordToggle && type === 'password')) ? 'right-12' : 'right-2'}`}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showPasswordToggle && type === 'password' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
aria-label="Toggle visibility"
|
||||
className="absolute top-1/2 -translate-y-1/2 right-2 p-2 text-on-surface-variant hover:text-on-surface rounded-full transition-opacity"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? <EyeOff size={20} /> : <Eye size={20} />}
|
||||
</button>
|
||||
)}
|
||||
{
|
||||
rightElement && (
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2 z-10">
|
||||
|
||||
160
src/components/InitializeAccount.tsx
Normal file
160
src/components/InitializeAccount.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import React, { useState } from 'react';
|
||||
import { initializeAccount } from '../services/auth';
|
||||
import { User, Language } from '../types';
|
||||
import { Globe, ArrowRight, Check, Calendar } from 'lucide-react';
|
||||
import { DatePicker } from './ui/DatePicker';
|
||||
import { t } from '../services/i18n';
|
||||
|
||||
interface InitializeAccountProps {
|
||||
onInitialized: (user: User) => void;
|
||||
language: Language;
|
||||
onLanguageChange: (lang: Language) => void;
|
||||
}
|
||||
|
||||
const InitializeAccount: React.FC<InitializeAccountProps> = ({ onInitialized, language, onLanguageChange }) => {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [birthDate, setBirthDate] = useState('');
|
||||
const [height, setHeight] = useState('');
|
||||
const [weight, setWeight] = useState('');
|
||||
const [gender, setGender] = useState<'MALE' | 'FEMALE' | 'OTHER'>('MALE');
|
||||
|
||||
const handleInitialize = async () => {
|
||||
setIsSubmitting(true);
|
||||
setError('');
|
||||
|
||||
const profileData: any = {};
|
||||
if (birthDate) profileData.birthDate = birthDate;
|
||||
if (height) profileData.height = parseFloat(height);
|
||||
if (weight) profileData.weight = parseFloat(weight);
|
||||
if (gender) profileData.gender = gender;
|
||||
|
||||
const res = await initializeAccount(language, profileData);
|
||||
if (res.success && res.user) {
|
||||
onInitialized(res.user);
|
||||
} else {
|
||||
setError(res.error || 'Failed to initialize account');
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const languages: { code: Language; label: string; desc: string }[] = [
|
||||
{ code: 'en', label: 'English', desc: t('init_lang_en_desc', language) },
|
||||
{ code: 'ru', label: 'Русский', desc: t('init_lang_ru_desc', language) },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-surface flex flex-col items-center justify-center p-6 sm:p-8">
|
||||
<div className="w-full max-w-sm bg-surface-container p-8 rounded-[28px] shadow-elevation-2 flex flex-col items-center max-h-[90vh] overflow-y-auto custom-scrollbar">
|
||||
<div className="w-16 h-16 bg-primary-container rounded-2xl flex items-center justify-center text-on-primary-container mb-6 shadow-elevation-1">
|
||||
<Globe size={32} />
|
||||
</div>
|
||||
|
||||
<h1 className="text-2xl font-normal text-on-surface mb-2 text-center">
|
||||
{t('init_title', language)}
|
||||
</h1>
|
||||
<p className="text-sm text-on-surface-variant mb-8 text-center balance">
|
||||
{t('init_desc', language)}
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="w-full text-error text-sm text-center bg-error-container/10 p-3 rounded-xl mb-6 border border-error/10">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="w-full space-y-3 mb-8">
|
||||
{languages.map((lang) => (
|
||||
<button
|
||||
key={lang.code}
|
||||
onClick={() => onLanguageChange(lang.code)}
|
||||
className={`w-full p-4 rounded-2xl border-2 transition-all flex items-center justify-between group ${language === lang.code
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-outline-variant/30 hover:border-outline-variant hover:bg-surface-container-high'
|
||||
}`}
|
||||
>
|
||||
<div className="text-left">
|
||||
<div className={`font-medium ${language === lang.code ? 'text-primary' : 'text-on-surface'}`}>
|
||||
{lang.label}
|
||||
</div>
|
||||
<div className="text-xs text-on-surface-variant">
|
||||
{lang.desc}
|
||||
</div>
|
||||
</div>
|
||||
{language === lang.code && (
|
||||
<div className="w-6 h-6 bg-primary text-on-primary rounded-full flex items-center justify-center">
|
||||
<Check size={14} strokeWidth={3} />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="w-full mb-8">
|
||||
<div className="space-y-4 animate-in fade-in slide-in-from-top-2 duration-300">
|
||||
<DatePicker
|
||||
label={t('birth_date', language)}
|
||||
value={birthDate}
|
||||
onChange={(val) => setBirthDate(val)}
|
||||
maxDate={new Date()}
|
||||
testId="init-birth-date"
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="height" className="block text-xs text-on-surface-variant mb-1 ml-1">{t('height', language)}</label>
|
||||
<input
|
||||
id="height"
|
||||
type="number"
|
||||
placeholder="cm"
|
||||
value={height}
|
||||
onChange={(e) => setHeight(e.target.value)}
|
||||
className="w-full p-4 rounded-2xl bg-surface-container-high border border-outline-variant/30 text-on-surface focus:border-primary focus:outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="weight" className="block text-xs text-on-surface-variant mb-1 ml-1">{t('bodyweight', language).split('(')[0].trim()}</label>
|
||||
<input
|
||||
id="weight"
|
||||
type="number"
|
||||
placeholder="kg"
|
||||
value={weight}
|
||||
onChange={(e) => setWeight(e.target.value)}
|
||||
className="w-full p-4 rounded-2xl bg-surface-container-high border border-outline-variant/30 text-on-surface focus:border-primary focus:outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="gender" className="block text-xs text-on-surface-variant mb-1 ml-1">{t('gender', language)}</label>
|
||||
<select
|
||||
id="gender"
|
||||
value={gender}
|
||||
onChange={(e) => setGender(e.target.value as any)}
|
||||
className="w-full p-4 rounded-2xl bg-surface-container-high border border-outline-variant/30 text-on-surface focus:border-primary focus:outline-none transition-all appearance-none"
|
||||
>
|
||||
<option value="MALE">{t('male', language)}</option>
|
||||
<option value="FEMALE">{t('female', language)}</option>
|
||||
<option value="OTHER">{t('other', language)}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleInitialize}
|
||||
disabled={isSubmitting}
|
||||
className="w-full py-4 bg-primary text-on-primary rounded-full font-medium text-lg shadow-elevation-1 flex items-center justify-center gap-2 hover:shadow-elevation-2 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<div className="w-6 h-6 border-2 border-on-primary/30 border-t-on-primary rounded-full animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
{t('init_start', language)} <ArrowRight size={20} />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InitializeAccount;
|
||||
@@ -41,7 +41,7 @@ const Login: React.FC<LoginProps> = ({ onLogin, language, onLanguageChange }) =>
|
||||
if (tempUser && newPassword.length >= 4) {
|
||||
const res = await changePassword(tempUser.id, newPassword);
|
||||
if (res.success) {
|
||||
const updatedUser = { ...tempUser, isFirstLogin: false };
|
||||
const updatedUser = { ...tempUser };
|
||||
onLogin(updatedUser);
|
||||
} else {
|
||||
setError(res.error || t('change_pass_error', language));
|
||||
@@ -66,6 +66,7 @@ const Login: React.FC<LoginProps> = ({ onLogin, language, onLanguageChange }) =>
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
type="password"
|
||||
showPasswordToggle
|
||||
/>
|
||||
<button
|
||||
onClick={handleChangePassword}
|
||||
@@ -119,6 +120,7 @@ const Login: React.FC<LoginProps> = ({ onLogin, language, onLanguageChange }) =>
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
type="password"
|
||||
icon={<Lock size={16} />}
|
||||
showPasswordToggle
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@ import { User, Language, ExerciseDef, ExerciseType, BodyWeightRecord } from '../
|
||||
import { createUser, changePassword, updateUserProfile, getCurrentUserProfile, getUsers, deleteUser, toggleBlockUser, adminResetPassword, getMe } from '../services/auth';
|
||||
import { getExercises, saveExercise } from '../services/storage';
|
||||
import { getWeightHistory, logWeight } from '../services/weight';
|
||||
import { User as UserIcon, LogOut, Save, Shield, UserPlus, Lock, Calendar, Ruler, Scale, PersonStanding, Globe, ChevronDown, ChevronUp, Trash2, Ban, KeyRound, Dumbbell, Archive, ArchiveRestore, Pencil, Plus, RefreshCcw } from 'lucide-react';
|
||||
import { generatePassword } from '../utils/password';
|
||||
import { User as UserIcon, LogOut, Save, Shield, UserPlus, Lock, Calendar, Ruler, Scale, PersonStanding, Globe, ChevronDown, ChevronUp, Trash2, Ban, KeyRound, Dumbbell, Archive, ArchiveRestore, Pencil, Plus, RefreshCcw, Sparkles } from 'lucide-react';
|
||||
import ExerciseModal from './ExerciseModal';
|
||||
import FilledInput from './FilledInput';
|
||||
import { t } from '../services/i18n';
|
||||
@@ -115,7 +116,7 @@ const Profile: React.FC<ProfileProps> = ({ user, onLogout, lang, onLanguageChang
|
||||
};
|
||||
|
||||
const refreshExercises = async () => {
|
||||
const exercises = await getExercises(user.id);
|
||||
const exercises = await getExercises(user.id, true);
|
||||
|
||||
setExercises(exercises);
|
||||
};
|
||||
@@ -301,8 +302,9 @@ const Profile: React.FC<ProfileProps> = ({ user, onLogout, lang, onLanguageChang
|
||||
<h3 className="text-sm font-bold text-primary mb-4">{t('personal_data', lang)}</h3>
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div className="bg-surface-container-high rounded-t-lg border-b border-outline-variant px-3 py-2">
|
||||
<label className="text-[10px] text-on-surface-variant flex gap-1 items-center"><Scale size={10} /> {t('weight_kg', lang)}</label>
|
||||
<label htmlFor="profileWeight" className="text-[10px] text-on-surface-variant flex gap-1 items-center"><Scale size={10} /> {t('bodyweight', lang)}</label>
|
||||
<input
|
||||
id="profileWeight"
|
||||
data-testid="profile-weight-input"
|
||||
type="number"
|
||||
step="0.1"
|
||||
@@ -312,8 +314,8 @@ const Profile: React.FC<ProfileProps> = ({ user, onLogout, lang, onLanguageChang
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-surface-container-high rounded-t-lg border-b border-outline-variant px-3 py-2">
|
||||
<label className="text-[10px] text-on-surface-variant flex gap-1 items-center"><Ruler size={10} /> {t('height', lang)}</label>
|
||||
<input data-testid="profile-height-input" type="number" value={height} onChange={(e) => setHeight(e.target.value)} className="w-full bg-transparent text-on-surface focus:outline-none" />
|
||||
<label htmlFor="profileHeight" className="text-[10px] text-on-surface-variant flex gap-1 items-center"><Ruler size={10} /> {t('height', lang)}</label>
|
||||
<input id="profileHeight" data-testid="profile-height-input" type="number" value={height} onChange={(e) => setHeight(e.target.value)} className="w-full bg-transparent text-on-surface focus:outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<DatePicker
|
||||
@@ -321,11 +323,12 @@ const Profile: React.FC<ProfileProps> = ({ user, onLogout, lang, onLanguageChang
|
||||
value={birthDate}
|
||||
onChange={(val) => setBirthDate(val)}
|
||||
testId="profile-birth-date"
|
||||
maxDate={new Date()}
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-surface-container-high rounded-t-lg border-b border-outline-variant px-3 py-2">
|
||||
<label className="text-[10px] text-on-surface-variant flex gap-1 items-center"><PersonStanding size={10} /> {t('gender', lang)}</label>
|
||||
<select data-testid="profile-gender" value={gender} onChange={(e) => setGender(e.target.value)} className="w-full bg-transparent text-on-surface focus:outline-none text-sm py-1 bg-surface-container-high">
|
||||
<label htmlFor="profileGender" className="text-[10px] text-on-surface-variant flex gap-1 items-center"><PersonStanding size={10} /> {t('gender', lang)}</label>
|
||||
<select id="profileGender" data-testid="profile-gender" value={gender} onChange={(e) => setGender(e.target.value)} className="w-full bg-transparent text-on-surface focus:outline-none text-sm py-1 bg-surface-container-high">
|
||||
<option value="MALE">{t('male', lang)}</option>
|
||||
<option value="FEMALE">{t('female', lang)}</option>
|
||||
<option value="OTHER">{t('other', lang)}</option>
|
||||
@@ -477,15 +480,17 @@ const Profile: React.FC<ProfileProps> = ({ user, onLogout, lang, onLanguageChang
|
||||
{/* Change Password */}
|
||||
<Card>
|
||||
<h3 className="text-sm font-bold text-primary mb-4 flex items-center gap-2"><Lock size={14} /> {t('change_pass_btn', lang)}</h3>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="password"
|
||||
placeholder={t('change_pass_new', lang)}
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
className="flex-1 bg-surface-container-high border-b border-outline-variant px-3 py-2 text-on-surface focus:outline-none rounded-t-lg"
|
||||
/>
|
||||
<Button onClick={handleChangePassword} size="sm" variant="secondary">OK</Button>
|
||||
<div className="flex gap-2 items-end">
|
||||
<div className="flex-1">
|
||||
<FilledInput
|
||||
label={t('change_pass_new', lang)}
|
||||
value={newPassword}
|
||||
onChange={(e: any) => setNewPassword(e.target.value)}
|
||||
type="password"
|
||||
showPasswordToggle
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={handleChangePassword} className="mb-0.5">OK</Button>
|
||||
</div>
|
||||
{passMsg && <p className="text-xs text-primary mt-2">{passMsg}</p>}
|
||||
</Card>
|
||||
@@ -532,6 +537,16 @@ const Profile: React.FC<ProfileProps> = ({ user, onLogout, lang, onLanguageChang
|
||||
value={newUserPass}
|
||||
onChange={(e) => setNewUserPass(e.target.value)}
|
||||
type="text"
|
||||
rightElement={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setNewUserPass(generatePassword(8))}
|
||||
className="p-2 text-primary hover:bg-primary/10 rounded-full transition-colors"
|
||||
title="Generate"
|
||||
>
|
||||
<Sparkles size={20} />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<Button onClick={handleCreateUser} fullWidth>
|
||||
{t('create_btn', lang)}
|
||||
@@ -600,20 +615,19 @@ const Profile: React.FC<ProfileProps> = ({ user, onLogout, lang, onLanguageChang
|
||||
</div>
|
||||
|
||||
{u.role !== 'ADMIN' && (
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex-1 flex items-center bg-surface-container rounded px-2 border border-outline-variant/20">
|
||||
<KeyRound size={12} className="text-on-surface-variant mr-2" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('change_pass_new', lang)}
|
||||
className="bg-transparent text-xs py-2 w-full focus:outline-none text-on-surface"
|
||||
<div className="flex gap-2 items-end">
|
||||
<div className="flex-1">
|
||||
<FilledInput
|
||||
label={t('change_pass_new', lang)}
|
||||
value={adminPassResetInput[u.id] || ''}
|
||||
onChange={(e) => setAdminPassResetInput({ ...adminPassResetInput, [u.id]: e.target.value })}
|
||||
onChange={(e: any) => setAdminPassResetInput({ ...adminPassResetInput, [u.id]: e.target.value })}
|
||||
type="password"
|
||||
showPasswordToggle
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => handleAdminResetPass(u.id)}
|
||||
size="sm"
|
||||
className="mb-0.5"
|
||||
variant="secondary"
|
||||
>
|
||||
{t('reset_pass', lang)}
|
||||
|
||||
@@ -95,7 +95,7 @@ const IdleView: React.FC<IdleViewProps> = ({ tracker, lang }) => {
|
||||
<div className="w-full max-w-sm bg-surface-container rounded-2xl p-6 flex flex-col items-center gap-4 shadow-elevation-1">
|
||||
<label className="text-xs text-on-surface-variant font-bold tracking-wide flex items-center gap-2">
|
||||
<User size={14} />
|
||||
{t('my_weight', lang)}
|
||||
{t('bodyweight', lang)}
|
||||
</label>
|
||||
<div className="flex items-center gap-4">
|
||||
<input
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { CheckCircle, Plus, Pencil, Trash2, X, Save } from 'lucide-react';
|
||||
import { TopBar } from '../ui/TopBar';
|
||||
import { Language, WorkoutSet } from '../../types';
|
||||
import { t } from '../../services/i18n';
|
||||
import ExerciseModal from '../ExerciseModal';
|
||||
@@ -76,8 +77,14 @@ const SporadicView: React.FC<SporadicViewProps> = ({ tracker, lang }) => {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full max-h-full overflow-hidden relative bg-surface">
|
||||
<div className="px-4 py-3 bg-surface-container shadow-elevation-1 z-20 grid grid-cols-[1fr_auto_1fr] items-center">
|
||||
<div className="flex justify-start">
|
||||
<TopBar
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-primary animate-pulse" />
|
||||
{t('quick_log', lang)}
|
||||
</span>
|
||||
}
|
||||
actions={
|
||||
<button
|
||||
onClick={() => {
|
||||
resetForm();
|
||||
@@ -87,16 +94,8 @@ const SporadicView: React.FC<SporadicViewProps> = ({ tracker, lang }) => {
|
||||
>
|
||||
{t('quit', lang)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col items-center">
|
||||
<h2 className="text-title-medium text-on-surface flex items-center gap-2 font-medium">
|
||||
<span className="w-2 h-2 rounded-full bg-primary animate-pulse" />
|
||||
{t('quick_log', lang)}
|
||||
</h2>
|
||||
</div>
|
||||
<div />
|
||||
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 pb-32 space-y-6">
|
||||
<SetLogger
|
||||
|
||||
@@ -140,6 +140,8 @@ export const useTracker = (props: any) => { // Props ignored/removed
|
||||
const updateSelection = async () => {
|
||||
if (selectedExercise) {
|
||||
setSearchQuery(selectedExercise.name);
|
||||
// Reset form synchronously to clear previous exercise data immediately
|
||||
form.resetForm();
|
||||
await form.updateFormFromLastSet(selectedExercise.id, selectedExercise.type, selectedExercise.bodyWeightPercentage);
|
||||
} else {
|
||||
setSearchQuery('');
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useRef, useEffect, useId } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Calendar as CalendarIcon, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { Calendar as CalendarIcon, ChevronLeft, ChevronRight, ChevronDown } from 'lucide-react';
|
||||
import { Button } from './Button';
|
||||
import { Ripple } from './Ripple';
|
||||
|
||||
@@ -12,6 +12,7 @@ interface DatePickerProps {
|
||||
icon?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
testId?: string;
|
||||
maxDate?: Date; // Optional maximum date constraint
|
||||
}
|
||||
|
||||
export const DatePicker: React.FC<DatePickerProps> = ({
|
||||
@@ -21,7 +22,8 @@ export const DatePicker: React.FC<DatePickerProps> = ({
|
||||
placeholder = 'Select date',
|
||||
icon = <CalendarIcon size={16} />,
|
||||
disabled = false,
|
||||
testId
|
||||
testId,
|
||||
maxDate
|
||||
}) => {
|
||||
const id = useId();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
@@ -30,6 +32,11 @@ export const DatePicker: React.FC<DatePickerProps> = ({
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// MD3 Enhancement: Calendar view and text input states
|
||||
const [calendarView, setCalendarView] = useState<'days' | 'months' | 'years'>('days');
|
||||
const [textInputValue, setTextInputValue] = useState('');
|
||||
const [textInputError, setTextInputError] = useState('');
|
||||
|
||||
// Update popover position when opening or when window resizes
|
||||
const updatePosition = () => {
|
||||
if (containerRef.current) {
|
||||
@@ -100,6 +107,10 @@ export const DatePicker: React.FC<DatePickerProps> = ({
|
||||
const nextOpen = !isOpen;
|
||||
if (nextOpen) {
|
||||
updatePosition();
|
||||
// MD3 Enhancement: Reset to days view when opening
|
||||
setCalendarView('days');
|
||||
setTextInputValue('');
|
||||
setTextInputError('');
|
||||
}
|
||||
setIsOpen(nextOpen);
|
||||
}
|
||||
@@ -122,57 +133,257 @@ export const DatePicker: React.FC<DatePickerProps> = ({
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
// MD3 Enhancement: Year selection handler
|
||||
const handleYearSelect = (year: number) => {
|
||||
setViewDate(new Date(year, viewDate.getMonth(), 1));
|
||||
setCalendarView('days');
|
||||
};
|
||||
|
||||
// MD3 Enhancement: Text input validation and handling
|
||||
const validateDateInput = (input: string): boolean => {
|
||||
// Check format YYYY-MM-DD
|
||||
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
|
||||
if (!dateRegex.test(input)) {
|
||||
setTextInputError('Format: YYYY-MM-DD');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if date is valid
|
||||
const date = new Date(input);
|
||||
if (isNaN(date.getTime())) {
|
||||
setTextInputError('Invalid date');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if the input matches the parsed date (catches invalid dates like 2023-02-30)
|
||||
const [year, month, day] = input.split('-').map(Number);
|
||||
if (date.getFullYear() !== year || date.getMonth() + 1 !== month || date.getDate() !== day) {
|
||||
setTextInputError('Invalid date');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check against maxDate constraint
|
||||
if (maxDate && date > maxDate) {
|
||||
setTextInputError('Date cannot be in the future');
|
||||
return false;
|
||||
}
|
||||
|
||||
setTextInputError('');
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleTextInputSubmit = () => {
|
||||
if (validateDateInput(textInputValue)) {
|
||||
onChange(textInputValue);
|
||||
setIsOpen(false);
|
||||
setTextInputValue('');
|
||||
}
|
||||
};
|
||||
|
||||
const daysInMonth = (year: number, month: number) => new Date(year, month + 1, 0).getDate();
|
||||
const firstDayOfMonth = (year: number, month: number) => new Date(year, month, 1).getDay();
|
||||
|
||||
|
||||
const renderCalendar = () => {
|
||||
const year = viewDate.getFullYear();
|
||||
const month = viewDate.getMonth();
|
||||
const daysCount = daysInMonth(year, month);
|
||||
const startingDay = firstDayOfMonth(year, month);
|
||||
const monthName = viewDate.toLocaleString('default', { month: 'long' });
|
||||
|
||||
const days = [];
|
||||
for (let i = 0; i < startingDay; i++) {
|
||||
days.push(<div key={`empty-${i}`} className="w-10 h-10" />);
|
||||
}
|
||||
const monthName = viewDate.toLocaleString('en-US', { month: 'long' });
|
||||
const shortMonthName = viewDate.toLocaleString('en-US', { month: 'short' });
|
||||
|
||||
// Format selected date for header display
|
||||
const selectedDate = value ? new Date(value) : null;
|
||||
const isSelected = (d: number) => {
|
||||
return selectedDate &&
|
||||
selectedDate.getFullYear() === year &&
|
||||
selectedDate.getMonth() === month &&
|
||||
selectedDate.getDate() === d;
|
||||
};
|
||||
const headerDateText = selectedDate
|
||||
? selectedDate.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })
|
||||
: 'Select date';
|
||||
|
||||
const today = new Date();
|
||||
const isToday = (d: number) => {
|
||||
return today.getFullYear() === year &&
|
||||
today.getMonth() === month &&
|
||||
today.getDate() === d;
|
||||
};
|
||||
// Render year selection view
|
||||
const renderYearsView = () => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const maxYear = maxDate ? maxDate.getFullYear() : currentYear + 100;
|
||||
const years = [];
|
||||
// Generate years from current year going backwards (recent first)
|
||||
for (let y = maxYear; y >= currentYear - 100; y--) {
|
||||
years.push(y);
|
||||
}
|
||||
|
||||
for (let d = 1; d <= daysCount; d++) {
|
||||
days.push(
|
||||
<button
|
||||
key={d}
|
||||
type="button"
|
||||
onClick={() => handleDateSelect(d)}
|
||||
className={`
|
||||
relative w-10 h-10 rounded-full flex items-center justify-center text-sm transition-colors
|
||||
${isSelected(d)
|
||||
? 'bg-primary text-on-primary font-bold'
|
||||
: isToday(d)
|
||||
? 'text-primary border border-primary font-medium'
|
||||
: 'text-on-surface hover:bg-surface-container-high'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Ripple color={isSelected(d) ? 'rgba(255,255,255,0.3)' : 'rgba(var(--primary-rgb), 0.1)'} />
|
||||
{d}
|
||||
</button>
|
||||
const selectedYear = selectedDate?.getFullYear();
|
||||
|
||||
return (
|
||||
<div className="h-64 overflow-y-auto">
|
||||
<div className="grid grid-cols-3 gap-2 p-2">
|
||||
{years.map(y => (
|
||||
<button
|
||||
key={y}
|
||||
type="button"
|
||||
onClick={() => handleYearSelect(y)}
|
||||
className={`
|
||||
relative py-3 rounded-lg text-sm transition-colors
|
||||
${y === year
|
||||
? 'bg-primary text-on-primary font-bold'
|
||||
: y === currentYear
|
||||
? 'text-primary border border-primary font-medium'
|
||||
: 'text-on-surface hover:bg-surface-container-high'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Ripple color={y === year ? 'rgba(255,255,255,0.3)' : 'rgba(var(--primary-rgb), 0.1)'} />
|
||||
{y}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Render month selection view
|
||||
const renderMonthsView = () => {
|
||||
const months = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December'
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="h-64 overflow-y-auto">
|
||||
<div className="space-y-1">
|
||||
{months.map((monthLabel, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setViewDate(new Date(year, i, 1));
|
||||
setCalendarView('days');
|
||||
}}
|
||||
className={`
|
||||
relative w-full text-left px-4 py-3 rounded-lg text-sm transition-colors flex items-center gap-2
|
||||
${i === month
|
||||
? 'bg-secondary-container text-on-secondary-container font-medium'
|
||||
: 'text-on-surface hover:bg-surface-container-high'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{i === month && (
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
)}
|
||||
<span className={i === month ? '' : 'ml-7'}>{monthLabel}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Render days view
|
||||
const renderDaysView = () => {
|
||||
const days = [];
|
||||
for (let i = 0; i < startingDay; i++) {
|
||||
days.push(<div key={`empty-${i}`} className="w-10 h-10" />);
|
||||
}
|
||||
|
||||
const isSelected = (d: number) => {
|
||||
return selectedDate &&
|
||||
selectedDate.getFullYear() === year &&
|
||||
selectedDate.getMonth() === month &&
|
||||
selectedDate.getDate() === d;
|
||||
};
|
||||
|
||||
const today = new Date();
|
||||
const isToday = (d: number) => {
|
||||
return today.getFullYear() === year &&
|
||||
today.getMonth() === month &&
|
||||
today.getDate() === d;
|
||||
};
|
||||
|
||||
// Check if a date is disabled (after maxDate)
|
||||
const isDisabled = (d: number) => {
|
||||
if (!maxDate) return false;
|
||||
const checkDate = new Date(year, month, d);
|
||||
return checkDate > maxDate;
|
||||
};
|
||||
|
||||
for (let d = 1; d <= daysCount; d++) {
|
||||
const disabled = isDisabled(d);
|
||||
days.push(
|
||||
<button
|
||||
key={d}
|
||||
type="button"
|
||||
onClick={() => !disabled && handleDateSelect(d)}
|
||||
disabled={disabled}
|
||||
className={`
|
||||
relative w-10 h-10 rounded-full flex items-center justify-center text-sm transition-colors
|
||||
${disabled
|
||||
? 'text-on-surface/30 cursor-not-allowed'
|
||||
: isSelected(d)
|
||||
? 'bg-primary text-on-primary font-bold'
|
||||
: isToday(d)
|
||||
? 'text-primary border border-primary font-medium'
|
||||
: 'text-on-surface hover:bg-surface-container-high'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{!disabled && <Ripple color={isSelected(d) ? 'rgba(255,255,255,0.3)' : 'rgba(var(--primary-rgb), 0.1)'} />}
|
||||
{d}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
{/* Month navigation */}
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" onClick={handlePrevMonth} className="h-8 w-8">
|
||||
<ChevronLeft size={18} />
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCalendarView('months')}
|
||||
className="flex items-center gap-1 hover:bg-surface-container rounded px-2 py-1 transition-colors text-sm font-medium text-on-surface"
|
||||
>
|
||||
{shortMonthName}
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
<Button variant="ghost" size="icon" onClick={handleNextMonth} className="h-8 w-8">
|
||||
<ChevronRight size={18} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Year navigation */}
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" onClick={() => setViewDate(new Date(viewDate.getFullYear() - 1, viewDate.getMonth(), 1))} className="h-8 w-8">
|
||||
<ChevronLeft size={18} />
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCalendarView('years')}
|
||||
className="flex items-center gap-1 hover:bg-surface-container rounded px-2 py-1 transition-colors text-sm font-medium text-on-surface"
|
||||
>
|
||||
{year}
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
<Button variant="ghost" size="icon" onClick={() => setViewDate(new Date(viewDate.getFullYear() + 1, viewDate.getMonth(), 1))} className="h-8 w-8">
|
||||
<ChevronRight size={18} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-1 mb-2">
|
||||
{['S', 'M', 'T', 'W', 'T', 'F', 'S'].map((day, i) => (
|
||||
<div key={i} className="text-center text-[10px] font-bold text-on-surface-variant h-8 flex items-center justify-center uppercase tracking-wider">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{days}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const calendarContent = (
|
||||
<div
|
||||
@@ -185,32 +396,16 @@ export const DatePicker: React.FC<DatePickerProps> = ({
|
||||
}}
|
||||
className="p-4 w-[320px] bg-surface-container-high rounded-2xl shadow-xl border border-outline-variant animate-in fade-in zoom-in duration-200 origin-top"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-on-surface">{monthName} {year}</span>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button variant="ghost" size="icon" onClick={handlePrevMonth} className="h-8 w-8">
|
||||
<ChevronLeft size={18} />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={handleNextMonth} className="h-8 w-8">
|
||||
<ChevronRight size={18} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-1 mb-2">
|
||||
{['S', 'M', 'T', 'W', 'T', 'F', 'S'].map((day, i) => (
|
||||
<div key={i} className="text-center text-[10px] font-bold text-on-surface-variant h-8 flex items-center justify-center uppercase tracking-wider">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{days}
|
||||
</div>
|
||||
{/* Calendar content based on view */}
|
||||
{calendarView === 'years' ? (
|
||||
renderYearsView()
|
||||
) : calendarView === 'months' ? (
|
||||
renderMonthsView()
|
||||
) : (
|
||||
renderDaysView()
|
||||
)}
|
||||
|
||||
{/* Footer with Cancel button */}
|
||||
<div className="mt-4 pt-2 border-t border-outline-variant flex justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => setIsOpen(false)}>
|
||||
Cancel
|
||||
@@ -222,32 +417,90 @@ export const DatePicker: React.FC<DatePickerProps> = ({
|
||||
return createPortal(calendarContent, document.body);
|
||||
};
|
||||
|
||||
const formattedValue = value ? new Date(value).toLocaleDateString() : '';
|
||||
// Display value in same format as editing (YYYY-MM-DD)
|
||||
const formattedValue = value || '';
|
||||
|
||||
return (
|
||||
<div className="relative w-full" ref={containerRef}>
|
||||
<div
|
||||
className={`
|
||||
relative group bg-surface-container-high rounded-t-[4px] border-b transition-all min-h-[56px] cursor-pointer
|
||||
relative group bg-surface-container-high rounded-t-[4px] border-b transition-all min-h-[56px]
|
||||
${isOpen ? 'border-primary border-b-2' : 'border-on-surface-variant hover:bg-on-surface/10'}
|
||||
${disabled ? 'opacity-50 cursor-not-allowed' : ''}
|
||||
`}
|
||||
onClick={handleInputClick}
|
||||
>
|
||||
<label className={`
|
||||
<label htmlFor={id} className={`
|
||||
absolute top-2 left-4 text-label-sm font-medium transition-colors flex items-center gap-1
|
||||
${isOpen ? 'text-primary' : 'text-on-surface-variant'}
|
||||
`}>
|
||||
{icon} {label}
|
||||
</label>
|
||||
|
||||
<div className="w-full h-[56px] pt-5 pb-1 pl-4 pr-10 text-body-lg text-on-surface flex items-center">
|
||||
{formattedValue || <span className="text-on-surface-variant/50">{placeholder}</span>}
|
||||
</div>
|
||||
<input
|
||||
id={id}
|
||||
type="text"
|
||||
value={textInputValue || formattedValue}
|
||||
onChange={(e) => {
|
||||
setTextInputValue(e.target.value);
|
||||
setTextInputError('');
|
||||
}}
|
||||
onFocus={() => {
|
||||
// Initialize text input with current value and open calendar
|
||||
setTextInputValue(value || '');
|
||||
if (!disabled && !isOpen) {
|
||||
updatePosition();
|
||||
setCalendarView('days');
|
||||
setIsOpen(true);
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
// Validate and apply on blur
|
||||
if (textInputValue && textInputValue !== value) {
|
||||
if (validateDateInput(textInputValue)) {
|
||||
onChange(textInputValue);
|
||||
setTextInputValue('');
|
||||
}
|
||||
} else if (!textInputValue) {
|
||||
setTextInputValue('');
|
||||
setTextInputError('');
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
if (textInputValue && validateDateInput(textInputValue)) {
|
||||
onChange(textInputValue);
|
||||
setTextInputValue('');
|
||||
setIsOpen(false);
|
||||
(e.target as HTMLInputElement).blur();
|
||||
} else if (!textInputValue) {
|
||||
setIsOpen(false);
|
||||
(e.target as HTMLInputElement).blur();
|
||||
}
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
setIsOpen(false);
|
||||
setTextInputValue('');
|
||||
setTextInputError('');
|
||||
(e.target as HTMLInputElement).blur();
|
||||
}
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
className="w-full h-[56px] pt-5 pb-1 pl-4 pr-10 text-body-lg text-on-surface bg-transparent focus:outline-none"
|
||||
/>
|
||||
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-on-surface-variant">
|
||||
{textInputError && (
|
||||
<span className="absolute bottom-[-18px] left-4 text-xs text-error">{textInputError}</span>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleInputClick}
|
||||
disabled={disabled}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-on-surface-variant hover:text-primary p-1 rounded-full transition-colors"
|
||||
>
|
||||
<CalendarIcon size={20} />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Timer, Play, Pause, RotateCcw, Edit2, Plus, Minus, X, Check } from 'lucide-react';
|
||||
import { Timer, Play, Pause, RotateCcw, Edit2, Plus, Minus, X, Check, Bell, BellOff } from 'lucide-react';
|
||||
import { useRestTimer } from '../../hooks/useRestTimer';
|
||||
import { requestNotificationPermission } from '../../utils/notifications';
|
||||
|
||||
interface RestTimerFABProps {
|
||||
timer: ReturnType<typeof useRestTimer>;
|
||||
@@ -21,6 +22,10 @@ const RestTimerFAB: React.FC<RestTimerFABProps> = ({ timer, onDurationChange })
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editValue, setEditValue] = useState(120);
|
||||
const [inputValue, setInputValue] = useState(formatSeconds(120));
|
||||
const [notificationPermission, setNotificationPermission] = useState<NotificationPermission>(
|
||||
'Notification' in window ? Notification.permission : 'default'
|
||||
);
|
||||
const [isSecure, setIsSecure] = useState(true);
|
||||
|
||||
// Auto-expand when running if not already expanded? No, requirement says "when time is running, show digits of the countdown on the enlarged timer FAB even if the menu is collapsed".
|
||||
// So the FAB itself grows.
|
||||
@@ -38,6 +43,20 @@ const RestTimerFAB: React.FC<RestTimerFABProps> = ({ timer, onDurationChange })
|
||||
setInputValue(formatSeconds(editValue));
|
||||
}, [editValue]);
|
||||
|
||||
// Check permission on mount and focus
|
||||
useEffect(() => {
|
||||
const checkState = () => {
|
||||
if ('Notification' in window) {
|
||||
setNotificationPermission(Notification.permission);
|
||||
}
|
||||
setIsSecure(window.isSecureContext);
|
||||
};
|
||||
|
||||
checkState();
|
||||
window.addEventListener('focus', checkState);
|
||||
return () => window.removeEventListener('focus', checkState);
|
||||
}, []);
|
||||
|
||||
const handleToggle = () => {
|
||||
if (isEditing) return; // Don't toggle if editing
|
||||
setIsExpanded(!isExpanded);
|
||||
@@ -54,6 +73,18 @@ const RestTimerFAB: React.FC<RestTimerFABProps> = ({ timer, onDurationChange })
|
||||
reset();
|
||||
};
|
||||
|
||||
const handleRequestPermission = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (!isSecure) {
|
||||
alert("Notifications require a secure context (HTTPS) or localhost.");
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await requestNotificationPermission();
|
||||
if (result) setNotificationPermission(result);
|
||||
};
|
||||
|
||||
const handleEdit = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const initialVal = timeLeft > 0 ? timeLeft : 120;
|
||||
@@ -189,6 +220,21 @@ const RestTimerFAB: React.FC<RestTimerFABProps> = ({ timer, onDurationChange })
|
||||
</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">
|
||||
{/* Notification Permission Button (Only if not granted) */}
|
||||
{notificationPermission !== 'granted' && 'Notification' in window && (
|
||||
<button
|
||||
onClick={handleRequestPermission}
|
||||
className={`w-10 h-10 flex items-center justify-center rounded-full shadow-elevation-2 transition-all ${isSecure
|
||||
? "bg-tertiary-container text-on-tertiary-container hover:brightness-95 hover:scale-110 animate-pulse"
|
||||
: "bg-surface-container-high text-outline"
|
||||
}`}
|
||||
aria-label={isSecure ? "Enable Notifications" : "Notifications Failed"}
|
||||
title={isSecure ? "Enable Notifications for Timer" : "HTTPS required for notifications"}
|
||||
>
|
||||
{isSecure ? <Bell size={18} /> : <BellOff size={18} />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Mini FABs */}
|
||||
<button onClick={handleEdit} className="w-10 h-10 flex items-center justify-center bg-surface-container-high text-on-surface hover:text-primary rounded-full shadow-elevation-2 hover:scale-110 transition-all" aria-label="Edit">
|
||||
<Edit2 size={18} />
|
||||
|
||||
@@ -2,20 +2,26 @@ import React from 'react';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
|
||||
interface TopBarProps {
|
||||
title: string;
|
||||
title: string | React.ReactNode;
|
||||
icon?: LucideIcon;
|
||||
actions?: React.ReactNode;
|
||||
leading?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const TopBar: React.FC<TopBarProps> = ({ title, icon: Icon, actions }) => {
|
||||
export const TopBar: React.FC<TopBarProps> = ({ title, icon: Icon, actions, leading }) => {
|
||||
return (
|
||||
<div className="p-4 bg-surface-container shadow-elevation-1 flex items-center gap-3 z-10 shrink-0 rounded-b-[24px]">
|
||||
{Icon && (
|
||||
{leading}
|
||||
{!leading && Icon && (
|
||||
<div className="w-10 h-10 rounded-full bg-secondary-container flex items-center justify-center">
|
||||
<Icon size={20} className="text-on-secondary-container" />
|
||||
</div>
|
||||
)}
|
||||
<h2 className="text-xl font-normal text-on-surface flex-1">{title}</h2>
|
||||
{typeof title === 'string' ? (
|
||||
<h2 className="text-xl font-normal text-on-surface flex-1">{title}</h2>
|
||||
) : (
|
||||
<div className="flex-1 text-xl font-normal text-on-surface">{title}</div>
|
||||
)}
|
||||
{actions && <div className="flex items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
81
src/hooks/timer.worker.ts
Normal file
81
src/hooks/timer.worker.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/* eslint-disable no-restricted-globals */
|
||||
// Web Worker to handle the timer interval in a background thread.
|
||||
// This prevents the timer from being throttled when the tab is inactive or screen is off.
|
||||
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
let targetEndTime: number | null = null;
|
||||
|
||||
self.onmessage = (e: MessageEvent) => {
|
||||
const { type, payload } = e.data;
|
||||
|
||||
switch (type) {
|
||||
case 'START':
|
||||
if (payload?.endTime) {
|
||||
targetEndTime = payload.endTime;
|
||||
|
||||
// Clear any existing interval
|
||||
if (intervalId) clearInterval(intervalId);
|
||||
|
||||
// Start a fast tick loop
|
||||
// We tick faster than 1s to ensure we don't miss the :00 mark by much
|
||||
intervalId = setInterval(() => {
|
||||
if (!targetEndTime) return;
|
||||
|
||||
const now = Date.now();
|
||||
const timeLeft = Math.max(0, Math.ceil((targetEndTime - now) / 1000));
|
||||
|
||||
// Send tick update
|
||||
self.postMessage({ type: 'TICK', timeLeft });
|
||||
|
||||
if (timeLeft <= 0) {
|
||||
self.postMessage({ type: 'FINISHED' });
|
||||
|
||||
// Fire notification directly from worker to bypass frozen main thread
|
||||
if ('Notification' in self && (self as any).Notification.permission === 'granted') {
|
||||
try {
|
||||
// Try ServiceWorker registration first (more reliable on mobile)
|
||||
// Cast to any because TS dedicated worker scope doesn't know about registration
|
||||
const swReg = (self as any).registration;
|
||||
if ('serviceWorker' in self.navigator && swReg && swReg.showNotification) {
|
||||
swReg.showNotification("Time's Up!", {
|
||||
body: "Rest period finished",
|
||||
icon: '/assets/favicon.svg',
|
||||
vibrate: [200, 100, 200],
|
||||
tag: 'rest-timer',
|
||||
renotify: true
|
||||
});
|
||||
} else {
|
||||
// Fallback to standard Notification API
|
||||
// Cast options to any to allow 'renotify' which might be missing in strict lib
|
||||
new Notification("Time's Up!", {
|
||||
body: "Rest period finished",
|
||||
icon: '/assets/favicon.svg',
|
||||
tag: 'rest-timer',
|
||||
['renotify' as any]: true,
|
||||
} as NotificationOptions);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Worker notification failed', e);
|
||||
}
|
||||
}
|
||||
|
||||
if (intervalId) clearInterval(intervalId);
|
||||
intervalId = null;
|
||||
targetEndTime = null;
|
||||
}
|
||||
}, 200); // 200ms check for responsiveness
|
||||
}
|
||||
break;
|
||||
|
||||
case 'PAUSE':
|
||||
case 'STOP':
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
intervalId = null;
|
||||
}
|
||||
targetEndTime = null;
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
export { };
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { playTimeUpSignal } from '../utils/audio';
|
||||
import { requestNotificationPermission, sendNotification, vibrateDevice } from '../utils/notifications';
|
||||
|
||||
export type TimerStatus = 'IDLE' | 'RUNNING' | 'PAUSED' | 'FINISHED';
|
||||
|
||||
@@ -63,35 +64,104 @@ export const useRestTimer = ({ defaultTime, onFinish }: UseRestTimerProps) => {
|
||||
const [status, setStatus] = useState<TimerStatus>(initialStatus);
|
||||
const [duration, setDuration] = useState(initialDuration);
|
||||
|
||||
|
||||
|
||||
// Worker reference
|
||||
const workerRef = useRef<Worker | null>(null);
|
||||
|
||||
// Initialize Worker
|
||||
useEffect(() => {
|
||||
// Create worker instance
|
||||
workerRef.current = new Worker(new URL('./timer.worker.ts', import.meta.url), { type: 'module' });
|
||||
|
||||
workerRef.current.onmessage = (e) => {
|
||||
const { type, timeLeft: workerTimeLeft } = e.data;
|
||||
|
||||
if (type === 'TICK') {
|
||||
if (document.hidden) {
|
||||
setTimeLeft(workerTimeLeft);
|
||||
}
|
||||
} else if (type === 'FINISHED') {
|
||||
// Worker says done.
|
||||
setStatus((prev) => {
|
||||
if (prev === 'FINISHED') return prev;
|
||||
|
||||
playTimeUpSignal();
|
||||
sendNotification("Time's Up!", "Rest period finished");
|
||||
vibrateDevice();
|
||||
|
||||
if (onFinish) onFinish();
|
||||
|
||||
// Cleanup RAF if it was running
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
endTimeRef.current = null;
|
||||
|
||||
return 'FINISHED';
|
||||
});
|
||||
setTimeLeft(0);
|
||||
}
|
||||
};
|
||||
|
||||
return () => {
|
||||
workerRef.current?.terminate();
|
||||
};
|
||||
}, [onFinish, duration]);
|
||||
|
||||
|
||||
// Recover worker if we restored a RUNNING state
|
||||
useEffect(() => {
|
||||
if (initialStatus === 'RUNNING' && savedState?.endTime) {
|
||||
if (workerRef.current) {
|
||||
workerRef.current.postMessage({
|
||||
type: 'START',
|
||||
payload: { endTime: savedState.endTime }
|
||||
});
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const endTimeRef = useRef<number | null>(savedState?.endTime || null);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const prevDefaultTimeRef = useRef(defaultTime);
|
||||
|
||||
// Tick function - defined before effects
|
||||
// Tick function - defined before effects (RAF version)
|
||||
const tick = useCallback(() => {
|
||||
if (!endTimeRef.current) return;
|
||||
const now = Date.now();
|
||||
const remaining = Math.max(0, Math.ceil((endTimeRef.current - now) / 1000));
|
||||
|
||||
// Only update state if it changed (to avoid extra renders, though React handles this)
|
||||
setTimeLeft(remaining);
|
||||
|
||||
if (remaining <= 0) {
|
||||
// Finished
|
||||
setStatus('FINISHED');
|
||||
playTimeUpSignal();
|
||||
if (onFinish) onFinish();
|
||||
sendNotification("Time's Up!", "Rest period finished");
|
||||
vibrateDevice();
|
||||
|
||||
if (onFinish) onFinish(); // Ensure this is only called once per finish
|
||||
|
||||
endTimeRef.current = null;
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
|
||||
endTimeRef.current = null; // Clear end time
|
||||
|
||||
// Auto-reset visuals after 3 seconds of "FINISHED" state?
|
||||
setTimeout(() => {
|
||||
setStatus(prev => prev === 'FINISHED' ? 'IDLE' : prev);
|
||||
setTimeLeft(prev => prev === 0 ? duration : prev);
|
||||
}, 3000);
|
||||
} else {
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
}
|
||||
}, [duration, onFinish]);
|
||||
|
||||
// Handle Auto-Reset when status becomes FINISHED (covers both active finish and restore-from-finished)
|
||||
useEffect(() => {
|
||||
if (status === 'FINISHED') {
|
||||
const timer = setTimeout(() => {
|
||||
setStatus(prev => prev === 'FINISHED' ? 'IDLE' : prev);
|
||||
setTimeLeft(prev => prev === 0 ? duration : prev);
|
||||
}, 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [status, duration]);
|
||||
|
||||
// Save to localStorage whenever relevant state changes
|
||||
useEffect(() => {
|
||||
const stateToSave = {
|
||||
@@ -143,17 +213,32 @@ export const useRestTimer = ({ defaultTime, onFinish }: UseRestTimerProps) => {
|
||||
|
||||
// If starting from IDLE or PAUSED
|
||||
const targetSeconds = status === 'PAUSED' ? timeLeft : duration;
|
||||
endTimeRef.current = Date.now() + targetSeconds * 1000;
|
||||
const endTime = Date.now() + targetSeconds * 1000;
|
||||
endTimeRef.current = endTime;
|
||||
|
||||
setStatus('RUNNING');
|
||||
// Effect will trigger tick
|
||||
|
||||
// Request Permissions strictly on user interaction
|
||||
requestNotificationPermission();
|
||||
|
||||
// Start Worker
|
||||
if (workerRef.current) {
|
||||
workerRef.current.postMessage({
|
||||
type: 'START',
|
||||
payload: { endTime }
|
||||
});
|
||||
}
|
||||
}, [status, timeLeft, duration]);
|
||||
|
||||
const pause = useCallback(() => {
|
||||
if (status !== 'RUNNING') return;
|
||||
setStatus('PAUSED');
|
||||
// Effect calls cancelAnimationFrame
|
||||
|
||||
endTimeRef.current = null;
|
||||
|
||||
if (workerRef.current) {
|
||||
workerRef.current.postMessage({ type: 'PAUSE' });
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
const reset = useCallback((newDuration?: number) => {
|
||||
@@ -161,8 +246,12 @@ export const useRestTimer = ({ defaultTime, onFinish }: UseRestTimerProps) => {
|
||||
setDuration(nextDuration);
|
||||
setTimeLeft(nextDuration);
|
||||
setStatus('IDLE');
|
||||
|
||||
endTimeRef.current = null;
|
||||
// Effect calls cancelAnimationFrame (since status becomes IDLE)
|
||||
|
||||
if (workerRef.current) {
|
||||
workerRef.current.postMessage({ type: 'STOP' });
|
||||
}
|
||||
}, [duration]);
|
||||
|
||||
const addTime = useCallback((seconds: number) => {
|
||||
@@ -173,7 +262,15 @@ export const useRestTimer = ({ defaultTime, onFinish }: UseRestTimerProps) => {
|
||||
// Add to current target
|
||||
if (endTimeRef.current) {
|
||||
endTimeRef.current += seconds * 1000;
|
||||
// Force immediate update to avoid flicker
|
||||
// Update Worker
|
||||
if (workerRef.current) {
|
||||
workerRef.current.postMessage({
|
||||
type: 'START',
|
||||
payload: { endTime: endTimeRef.current }
|
||||
});
|
||||
}
|
||||
|
||||
// Force immediate update locally to avoid flicker
|
||||
const now = Date.now();
|
||||
setTimeLeft(Math.max(0, Math.ceil((endTimeRef.current - now) / 1000)));
|
||||
}
|
||||
|
||||
@@ -41,22 +41,32 @@ export const useWorkoutForm = ({ userId, onSetAdded, onUpdateSet }: UseWorkoutFo
|
||||
setBwPercentage(bodyWeightPercentage ? bodyWeightPercentage.toString() : '100');
|
||||
|
||||
const set = await getLastSetForExercise(userId, exerciseId);
|
||||
|
||||
// Use functional updates to only set values if the user hasn't typed anything yet (value is empty string)
|
||||
if (set) {
|
||||
setWeight(set.weight?.toString() || '');
|
||||
setReps(set.reps?.toString() || '');
|
||||
setDuration(set.durationSeconds?.toString() || '');
|
||||
setDistance(set.distanceMeters?.toString() || '');
|
||||
setHeight(set.height?.toString() || '');
|
||||
} else {
|
||||
resetForm();
|
||||
setWeight(prev => prev === '' ? (set.weight?.toString() || '') : prev);
|
||||
setReps(prev => prev === '' ? (set.reps?.toString() || '') : prev);
|
||||
setDuration(prev => prev === '' ? (set.durationSeconds?.toString() || '') : prev);
|
||||
setDistance(prev => prev === '' ? (set.distanceMeters?.toString() || '') : prev);
|
||||
setHeight(prev => prev === '' ? (set.height?.toString() || '') : prev);
|
||||
}
|
||||
|
||||
// Clear irrelevant fields
|
||||
if (exerciseType !== ExerciseType.STRENGTH && exerciseType !== ExerciseType.BODYWEIGHT) setWeight('');
|
||||
if (exerciseType !== ExerciseType.STRENGTH && exerciseType !== ExerciseType.BODYWEIGHT && exerciseType !== ExerciseType.PLYOMETRIC) setReps('');
|
||||
if (exerciseType !== ExerciseType.CARDIO && exerciseType !== ExerciseType.STATIC) setDuration('');
|
||||
if (exerciseType !== ExerciseType.CARDIO && exerciseType !== ExerciseType.LONG_JUMP) setDistance('');
|
||||
if (exerciseType !== ExerciseType.HIGH_JUMP) setHeight('');
|
||||
// Clear irrelevant fields based on exercise type - this is safe as it clears fields that shouldn't be there
|
||||
if (exerciseType !== ExerciseType.STRENGTH && exerciseType !== ExerciseType.BODYWEIGHT) {
|
||||
setWeight(prev => (set && set.weight?.toString() === prev) || prev === '' ? '' : prev);
|
||||
}
|
||||
if (exerciseType !== ExerciseType.STRENGTH && exerciseType !== ExerciseType.BODYWEIGHT && exerciseType !== ExerciseType.PLYOMETRIC) {
|
||||
setReps(prev => (set && set.reps?.toString() === prev) || prev === '' ? '' : prev);
|
||||
}
|
||||
if (exerciseType !== ExerciseType.CARDIO && exerciseType !== ExerciseType.STATIC) {
|
||||
setDuration(prev => (set && set.durationSeconds?.toString() === prev) || prev === '' ? '' : prev);
|
||||
}
|
||||
if (exerciseType !== ExerciseType.CARDIO && exerciseType !== ExerciseType.LONG_JUMP) {
|
||||
setDistance(prev => (set && set.distanceMeters?.toString() === prev) || prev === '' ? '' : prev);
|
||||
}
|
||||
if (exerciseType !== ExerciseType.HIGH_JUMP) {
|
||||
setHeight(prev => (set && set.height?.toString() === prev) || prev === '' ? '' : prev);
|
||||
}
|
||||
};
|
||||
|
||||
const prepareSetData = (selectedExercise: ExerciseDef, isSporadic: boolean = false) => {
|
||||
|
||||
@@ -46,7 +46,6 @@ export const createUser = async (email: string, password: string): Promise<{ suc
|
||||
try {
|
||||
const res = await api.post<ApiResponse<{ user: User, token: string }>>('/auth/register', { email, password });
|
||||
if (res.success && res.data) {
|
||||
setAuthToken(res.data.token);
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: res.error };
|
||||
@@ -150,3 +149,19 @@ export const getMe = async (): Promise<{ success: boolean; user?: User; error?:
|
||||
return { success: false, error: 'Failed to fetch user' };
|
||||
}
|
||||
};
|
||||
export const initializeAccount = async (language: string, profile?: Partial<UserProfile>): Promise<{ success: boolean; user?: User; error?: string }> => {
|
||||
try {
|
||||
const res = await api.post<ApiResponse<{ user: User }>>('/auth/initialize', { language, ...profile });
|
||||
if (res.success && res.data) {
|
||||
return { success: true, user: res.data.user };
|
||||
}
|
||||
return { success: false, error: res.error };
|
||||
} catch (e: any) {
|
||||
try {
|
||||
const err = JSON.parse(e.message);
|
||||
return { success: false, error: err.error || 'Failed to initialize account' };
|
||||
} catch {
|
||||
return { success: false, error: 'Failed to initialize account' };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -7,9 +7,9 @@ interface ApiResponse<T> {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const getExercises = async (userId: string): Promise<ExerciseDef[]> => {
|
||||
export const getExercises = async (userId: string, includeArchived: boolean = false): Promise<ExerciseDef[]> => {
|
||||
try {
|
||||
const res = await api.get<ApiResponse<ExerciseDef[]>>('/exercises');
|
||||
const res = await api.get<ApiResponse<ExerciseDef[]>>(`/exercises${includeArchived ? '?includeArchived=true' : ''}`);
|
||||
return res.data || [];
|
||||
} catch {
|
||||
return [];
|
||||
|
||||
@@ -36,6 +36,13 @@ const translations = {
|
||||
register_btn: 'Register',
|
||||
have_account: 'Already have an account? Login',
|
||||
need_account: 'Need an account? Register',
|
||||
init_title: 'Setup Your Account',
|
||||
init_desc: 'Welcome! Choose your preferred language.',
|
||||
init_select_lang: 'Select Language',
|
||||
init_start: 'Get Started',
|
||||
init_lang_en_desc: 'GUI and default exercise names will be in English',
|
||||
init_lang_ru_desc: 'GUI and default exercise names will be in Russian',
|
||||
select_gender: 'Select Gender',
|
||||
|
||||
// General
|
||||
date: 'Date',
|
||||
@@ -44,7 +51,7 @@ const translations = {
|
||||
// Tracker
|
||||
ready_title: 'Ready?',
|
||||
ready_subtitle: 'Start your workout and break records.',
|
||||
my_weight: 'My Weight (kg)',
|
||||
bodyweight: 'Bodyweight (kg)',
|
||||
change_in_profile: 'Change in profile',
|
||||
last_workout_today: 'Last workout: Today',
|
||||
days_off: 'Days off training:',
|
||||
@@ -125,8 +132,8 @@ const translations = {
|
||||
my_plans: 'My Plans',
|
||||
no_plans_yet: 'No workout plans yet.',
|
||||
ask_ai_to_create: 'Ask your AI coach to create one',
|
||||
create_manually: 'Manually',
|
||||
create_with_ai: 'With AI',
|
||||
create_manually: 'Create Plan Manually',
|
||||
create_with_ai: 'Create Plan with AI',
|
||||
ai_plan_prompt_title: 'Create Plan with AI',
|
||||
ai_plan_prompt_placeholder: 'Any specific requirements? (optional)',
|
||||
generate: 'Generate',
|
||||
@@ -253,6 +260,13 @@ const translations = {
|
||||
register_btn: 'Зарегистрироваться',
|
||||
have_account: 'Уже есть аккаунт? Войти',
|
||||
need_account: 'Нет аккаунта? Регистрация',
|
||||
init_title: 'Настройка аккаунта',
|
||||
init_desc: 'Добро пожаловать! Выберите предпочтительный язык.',
|
||||
init_select_lang: 'Выберите язык',
|
||||
init_start: 'Начать работу',
|
||||
init_lang_en_desc: 'Интерфейс и названия упражнений по умолчанию будут на английском',
|
||||
init_lang_ru_desc: 'Интерфейс и названия упражнений по умолчанию будут на русском',
|
||||
select_gender: 'Выберите пол',
|
||||
|
||||
// General
|
||||
date: 'Дата',
|
||||
@@ -261,7 +275,7 @@ const translations = {
|
||||
// Tracker
|
||||
ready_title: 'Готовы?',
|
||||
ready_subtitle: 'Начните тренировку и побейте рекорды.',
|
||||
my_weight: 'Мой вес (кг)',
|
||||
bodyweight: 'Вес тела (кг)',
|
||||
change_in_profile: 'Можно изменить в профиле',
|
||||
last_workout_today: 'Последняя тренировка: Сегодня',
|
||||
days_off: 'Дней без тренировок:',
|
||||
|
||||
80
src/utils/notifications.ts
Normal file
80
src/utils/notifications.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Request notification permissions from the user.
|
||||
* Safe to call multiple times (idempotent).
|
||||
*/
|
||||
export const requestNotificationPermission = async () => {
|
||||
if (!('Notification' in window)) {
|
||||
console.log('This browser does not support desktop notification');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Current notification permission:', Notification.permission);
|
||||
|
||||
if (Notification.permission === 'granted') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Notification.permission !== 'denied') {
|
||||
try {
|
||||
const permission = await Notification.requestPermission();
|
||||
console.log('Notification permission request result:', permission);
|
||||
return permission;
|
||||
} catch (e) {
|
||||
console.error('Error requesting notification permission', e);
|
||||
}
|
||||
} else {
|
||||
console.warn('Notification permission is denied. User must enable it in settings.');
|
||||
}
|
||||
return Notification.permission;
|
||||
};
|
||||
|
||||
/**
|
||||
* Send a system notification.
|
||||
* @param title Notification title
|
||||
* @param body Notification body text
|
||||
*/
|
||||
export const sendNotification = (title: string, body?: string) => {
|
||||
if (!('Notification' in window)) return;
|
||||
|
||||
if (Notification.permission === 'granted') {
|
||||
try {
|
||||
// Check if service worker is available for more reliable notifications on mobile
|
||||
if ('serviceWorker' in navigator && navigator.serviceWorker.ready) {
|
||||
navigator.serviceWorker.ready.then(registration => {
|
||||
registration.showNotification(title, {
|
||||
body,
|
||||
icon: '/assets/favicon.svg',
|
||||
vibrate: [200, 100, 200],
|
||||
tag: 'rest-timer',
|
||||
renotify: true
|
||||
} as any); // Cast to any to allow extended properties
|
||||
});
|
||||
} else {
|
||||
// Fallback to standard notification API
|
||||
new Notification(title, {
|
||||
body,
|
||||
icon: '/assets/favicon.svg',
|
||||
tag: 'rest-timer',
|
||||
renotify: true,
|
||||
vibrate: [200, 100, 200]
|
||||
} as any);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error sending notification', e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Trigger device vibration.
|
||||
* @param pattern settings for navigator.vibrate
|
||||
*/
|
||||
export const vibrateDevice = (pattern: number | number[] = [200, 100, 200, 100, 200]) => {
|
||||
if ('vibrate' in navigator) {
|
||||
try {
|
||||
navigator.vibrate(pattern);
|
||||
} catch (e) {
|
||||
console.error('Error vibrating device', e);
|
||||
}
|
||||
}
|
||||
};
|
||||
9
src/utils/password.ts
Normal file
9
src/utils/password.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
|
||||
export function generatePassword(length = 8): string {
|
||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+";
|
||||
let password = "";
|
||||
for (let i = 0; i < length; i++) {
|
||||
password += charset.charAt(Math.floor(Math.random() * charset.length));
|
||||
}
|
||||
return password;
|
||||
}
|
||||
@@ -12,65 +12,54 @@ test.describe('I. Core & Authentication', () => {
|
||||
|
||||
// Helper to handle first login if needed
|
||||
async function handleFirstLogin(page: any) {
|
||||
// Wait for either Free Workout (already logged in/not first time)
|
||||
// OR Change Password heading
|
||||
// OR Error message
|
||||
console.log('Starting handleFirstLogin helper...');
|
||||
const dashboard = page.getByText(/Free Workout|Свободная тренировка/i).first();
|
||||
const changePass = page.getByRole('heading', { name: /Change Password|Смена пароля/i });
|
||||
const initAcc = page.getByRole('heading', { name: /Setup Your Account|Настройка аккаунта/i });
|
||||
|
||||
try {
|
||||
const heading = page.getByRole('heading', { name: /Change Password/i });
|
||||
const dashboard = page.getByText('Free Workout');
|
||||
const loginButton = page.getByRole('button', { name: 'Login' });
|
||||
await expect(dashboard.or(changePass).or(initAcc)).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Race condition: wait for one of these to appear
|
||||
// We use a small polling or just wait logic.
|
||||
// Playwright doesn't have "race" for locators easily without Promise.race
|
||||
if (await changePass.isVisible()) {
|
||||
console.log('Change Password screen detected. Handling...');
|
||||
await page.getByLabel(/New Password|Новый пароль/i).fill('StrongNewPass123!');
|
||||
await page.getByRole('button', { name: /Save|Change|Сохранить/i }).click();
|
||||
await expect(dashboard.or(initAcc)).toBeVisible({ timeout: 10000 });
|
||||
}
|
||||
|
||||
// Simple approach: Check if Change Password appears within 5s
|
||||
await expect(heading).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// If we are here, Change Password is visible
|
||||
console.log('Change Password screen detected. Handling...');
|
||||
await page.getByLabel('New Password').fill('StrongNewPass123!');
|
||||
await page.getByRole('button', { name: /Save|Change/i }).click();
|
||||
|
||||
// Now expect dashboard
|
||||
await expect(dashboard).toBeVisible();
|
||||
console.log('Password changed. Dashboard visible.');
|
||||
if (await initAcc.isVisible()) {
|
||||
console.log('Initialization screen detected. Handling...');
|
||||
await page.getByRole('button', { name: /Get Started|Начать работу/i }).click();
|
||||
await expect(dashboard).toBeVisible({ timeout: 10000 });
|
||||
}
|
||||
} catch (e) {
|
||||
// If Change Password didn't appear, maybe we are already at dashboard?
|
||||
if (await page.getByText('Free Workout').isVisible()) {
|
||||
console.log('Already at Dashboard.');
|
||||
return;
|
||||
}
|
||||
// Check for login error
|
||||
const error = page.locator('.text-error');
|
||||
if (await error.isVisible()) {
|
||||
console.log('Login Error detected:', await error.textContent());
|
||||
throw new Error(`Login failed: ${await error.textContent()}`);
|
||||
}
|
||||
// Note: If none of the above, it might be a clean login that just worked fast or failed silently
|
||||
console.log('handleFirstLogin timeout or already reached dashboard');
|
||||
}
|
||||
|
||||
// Final check with assertion to fail the test if not reached
|
||||
await expect(dashboard).toBeVisible({ timeout: 5000 });
|
||||
}
|
||||
|
||||
// 1.1. A. Login - Successful Authentication
|
||||
test('1.1 Login - Successful Authentication', async ({ page, createUniqueUser }) => {
|
||||
const user = await createUniqueUser();
|
||||
|
||||
await page.getByLabel('Email').fill(user.email);
|
||||
await page.getByLabel('Password').fill(user.password);
|
||||
await page.getByRole('button', { name: 'Login' }).click();
|
||||
await page.getByLabel(/Email/i).fill(user.email);
|
||||
await page.getByLabel(/Password|Пароль/i).fill(user.password);
|
||||
await page.getByRole('button', { name: /Login|Войти/i }).click();
|
||||
|
||||
await handleFirstLogin(page);
|
||||
|
||||
// Expect redirection to dashboard
|
||||
await expect(page).not.toHaveURL(/\/login/);
|
||||
await expect(page.getByText('Free Workout')).toBeVisible();
|
||||
await expect(page.getByText(/Free Workout|Свободная тренировка/i).first()).toBeVisible();
|
||||
});
|
||||
|
||||
// 1.2. A. Login - Invalid Credentials
|
||||
test('1.2 Login - Invalid Credentials', async ({ page }) => {
|
||||
await page.getByLabel('Email').fill('invalid@user.com');
|
||||
await page.getByLabel('Password').fill('wrongpassword');
|
||||
await page.getByRole('button', { name: 'Login' }).click();
|
||||
await page.getByLabel(/Email/i).fill('invalid@user.com');
|
||||
await page.getByLabel(/Password|Пароль/i).fill('wrongpassword');
|
||||
await page.getByRole('button', { name: /Login|Войти/i }).click();
|
||||
|
||||
await expect(page.getByText('Invalid credentials')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Login' })).toBeVisible();
|
||||
@@ -79,31 +68,34 @@ test.describe('I. Core & Authentication', () => {
|
||||
test('1.3 & 1.4 Login - First-Time Password Change', async ({ page, createUniqueUser }) => {
|
||||
const user = await createUniqueUser();
|
||||
|
||||
await page.getByLabel('Email').fill(user.email);
|
||||
await page.getByLabel('Password').fill(user.password);
|
||||
await page.getByRole('button', { name: 'Login' }).click();
|
||||
await page.getByLabel(/Email/i).fill(user.email);
|
||||
await page.getByLabel(/Password|Пароль/i).fill(user.password);
|
||||
await page.getByRole('button', { name: /Login|Войти/i }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: /Change Password/i }).first()).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByRole('heading', { name: /Change Password|Смена пароля/i }).first()).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// 1.4 Test short password
|
||||
await page.getByLabel('New Password').fill('123');
|
||||
await page.getByRole('button', { name: /Save|Change/i }).click();
|
||||
await expect(page.getByText('Password too short')).toBeVisible();
|
||||
await page.getByLabel(/New Password|Новый пароль/i).fill('123');
|
||||
await page.getByRole('button', { name: /Save|Change|Сохранить/i }).click();
|
||||
await expect(page.getByText(/Password too short|Пароль слишком короткий/i)).toBeVisible();
|
||||
|
||||
// 1.3 Test successful change
|
||||
await page.getByLabel('New Password').fill('StrongNewPass123!');
|
||||
await page.getByRole('button', { name: /Save|Change/i }).click();
|
||||
await page.getByLabel(/New Password|Новый пароль/i).fill('StrongNewPass123!');
|
||||
await page.getByRole('button', { name: /Save|Change|Сохранить/i }).click();
|
||||
|
||||
// Now we should be on Setup Account page
|
||||
await expect(page.getByRole('heading', { name: /Setup Your Account|Настройка аккаунта/i })).toBeVisible();
|
||||
await page.getByRole('button', { name: /Get Started|Начать работу/i }).click();
|
||||
|
||||
// Now we should be logged in
|
||||
await expect(page.getByText('Free Workout')).toBeVisible();
|
||||
await expect(page.getByText(/Free Workout|Свободная тренировка/i).first()).toBeVisible();
|
||||
});
|
||||
|
||||
|
||||
// 1.5. A. Login - Language Selection (English)
|
||||
test('1.5 Login - Language Selection (English)', async ({ page }) => {
|
||||
await page.getByRole('combobox').selectOption('en');
|
||||
await expect(page.getByLabel('Email')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Login' })).toBeVisible();
|
||||
await expect(page.getByLabel(/Email/i)).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /Login|Войти/i })).toBeVisible();
|
||||
});
|
||||
|
||||
// 1.6. A. Login - Language Selection (Russian)
|
||||
@@ -116,26 +108,26 @@ test.describe('I. Core & Authentication', () => {
|
||||
test('1.7 Navigation - Desktop Navigation Rail', async ({ page, createUniqueUser }) => {
|
||||
const user = await createUniqueUser();
|
||||
|
||||
await page.getByLabel('Email').fill(user.email);
|
||||
await page.getByLabel('Password').fill(user.password);
|
||||
await page.getByRole('button', { name: 'Login' }).click();
|
||||
await page.getByLabel(/Email/i).fill(user.email);
|
||||
await page.getByLabel(/Password|Пароль/i).fill(user.password);
|
||||
await page.getByRole('button', { name: /Login|Войти/i }).click();
|
||||
|
||||
await handleFirstLogin(page);
|
||||
|
||||
// Set viewport to desktop
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Tracker' }).first()).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Plans' }).first()).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /Tracker|Трекер/i }).first()).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /Plans|Планы/i }).first()).toBeVisible();
|
||||
});
|
||||
|
||||
// 1.8. B. Navigation - Mobile Bottom Navigation Bar
|
||||
test('1.8 Navigation - Mobile Bottom Navigation Bar', async ({ page, createUniqueUser }) => {
|
||||
const user = await createUniqueUser();
|
||||
|
||||
await page.getByLabel('Email').fill(user.email);
|
||||
await page.getByLabel('Password').fill(user.password);
|
||||
await page.getByRole('button', { name: 'Login' }).click();
|
||||
await page.getByLabel(/Email/i).fill(user.email);
|
||||
await page.getByLabel(/Password|Пароль/i).fill(user.password);
|
||||
await page.getByRole('button', { name: /Login|Войти/i }).click();
|
||||
|
||||
await handleFirstLogin(page);
|
||||
|
||||
@@ -145,7 +137,81 @@ test.describe('I. Core & Authentication', () => {
|
||||
await page.waitForTimeout(500); // Allow layout transition
|
||||
|
||||
// Verify visibility of mobile nav items
|
||||
await expect(page.getByRole('button', { name: 'Tracker' }).last()).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /Tracker|Трекер/i }).last()).toBeVisible();
|
||||
});
|
||||
|
||||
// 1.9. C. Initialization - Russian Language Seeding
|
||||
test('1.9 Initialization - Russian Language Seeding', async ({ page, createUniqueUser }) => {
|
||||
const user = await createUniqueUser();
|
||||
|
||||
await page.getByLabel(/Email/i).fill(user.email);
|
||||
await page.getByLabel(/Password|Пароль/i).fill(user.password);
|
||||
await page.getByRole('button', { name: /Login|Войти/i }).click();
|
||||
|
||||
// Handle password change
|
||||
await expect(page.getByRole('heading', { name: /Change Password|Смена пароля/i })).toBeVisible();
|
||||
await page.getByLabel(/New Password|Новый пароль/i).fill('StrongNewPass123!');
|
||||
await page.getByRole('button', { name: /Save|Change|Сохранить/i }).click();
|
||||
|
||||
// Handle initialization - Select Russian
|
||||
await expect(page.getByRole('heading', { name: /Setup Your Account|Настройка аккаунта/i })).toBeVisible();
|
||||
await page.getByText('Русский').click();
|
||||
await page.getByRole('button', { name: /Начать работу|Get Started/i }).click();
|
||||
|
||||
// Expect dashboard
|
||||
await expect(page.getByText('Свободная тренировка')).toBeVisible();
|
||||
|
||||
// Verify some exercise is in Russian
|
||||
await page.getByText(/Свободная тренировка|Free Workout/i).first().click();
|
||||
await page.getByLabel(/Выберите упражнение|Select Exercise/i).click();
|
||||
|
||||
// "Air Squats" should be "Приседания" in suggestions
|
||||
await expect(page.getByRole('button', { name: 'Приседания', exact: true })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Приседания', exact: true }).click();
|
||||
|
||||
// Verify it's selected in the input
|
||||
const exerciseInput = page.getByLabel(/Выберите упражнение|Select Exercise/i);
|
||||
await expect(exerciseInput).toHaveValue('Приседания');
|
||||
|
||||
// Verify "Log Set" button is now in Russian
|
||||
await expect(page.getByRole('button', { name: /Записать подход|Log Set/i })).toBeVisible();
|
||||
});
|
||||
|
||||
// 1.10. C. Initialization - Optional Profile Data
|
||||
test('1.10 Initialization - Optional Profile Data', async ({ page, createUniqueUser }) => {
|
||||
const user = await createUniqueUser();
|
||||
|
||||
await page.getByLabel(/Email/i).fill(user.email);
|
||||
await page.getByLabel(/Password|Пароль/i).fill(user.password);
|
||||
await page.getByRole('button', { name: /Login|Войти/i }).click();
|
||||
|
||||
// Handle password change
|
||||
await expect(page.getByRole('heading', { name: /Change Password|Смена пароля/i })).toBeVisible();
|
||||
await page.getByLabel(/New Password|Новый пароль/i).fill('StrongNewPass123!');
|
||||
await page.getByRole('button', { name: /Save|Change|Сохранить/i }).click();
|
||||
|
||||
// Handle initialization
|
||||
await expect(page.getByRole('heading', { name: /Setup Your Account|Настройка аккаунта/i })).toBeVisible();
|
||||
|
||||
// Fill data
|
||||
await page.getByLabel(/Birth Date|Дата рожд./i).fill('1990-01-01');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.getByLabel(/Height|Рост/i).fill('180');
|
||||
await page.getByLabel(/Bodyweight|Вес тела/i).fill('80');
|
||||
await page.getByLabel(/Gender|Пол/i).selectOption('MALE');
|
||||
|
||||
await page.getByRole('button', { name: /Get Started|Начать работу/i }).click();
|
||||
|
||||
// Expect dashboard
|
||||
await expect(page.getByText(/Free Workout|Свободная тренировка/i).first()).toBeVisible();
|
||||
|
||||
// Navigate to profile to verify
|
||||
await page.getByRole('button', { name: /Profile|Профиль/i }).first().click();
|
||||
|
||||
// Verify values in Profile section
|
||||
await expect(page.getByLabel(/Height|Рост/i)).toHaveValue('180');
|
||||
await expect(page.getByLabel(/Birth Date|Дата рожд./i)).toHaveValue('1990-01-01');
|
||||
await expect(page.getByLabel(/Gender|Пол/i)).toHaveValue('MALE');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -15,11 +15,18 @@ test.describe('II. Workout Management', () => {
|
||||
|
||||
try {
|
||||
const heading = page.getByRole('heading', { name: /Change Password/i });
|
||||
const initAcc = page.getByRole('heading', { name: /Setup Your Account/i });
|
||||
const dashboard = page.getByText('Free Workout');
|
||||
await expect(heading.or(dashboard)).toBeVisible({ timeout: 5000 });
|
||||
await expect(heading.or(initAcc).or(dashboard)).toBeVisible({ timeout: 5000 });
|
||||
|
||||
if (await heading.isVisible()) {
|
||||
await page.getByLabel('New Password').fill('StrongNewPass123!');
|
||||
await page.getByRole('button', { name: /Save|Change/i }).click();
|
||||
await expect(initAcc.or(dashboard)).toBeVisible();
|
||||
}
|
||||
|
||||
if (await initAcc.isVisible()) {
|
||||
await page.getByRole('button', { name: /Get Started/i }).click();
|
||||
await expect(dashboard).toBeVisible();
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -43,7 +50,7 @@ test.describe('II. Workout Management', () => {
|
||||
|
||||
await page.getByRole('button', { name: 'Plans' }).first().click();
|
||||
await page.getByRole('button', { name: 'Create Plan' }).click();
|
||||
await page.getByRole('button', { name: 'Manually' }).click();
|
||||
await page.getByRole('button', { name: 'Create Plan Manually' }).click();
|
||||
await expect(page.getByLabel(/Name/i)).toBeVisible({ timeout: 10000 });
|
||||
|
||||
await page.getByLabel(`Name`).fill('My New Strength Plan');
|
||||
@@ -296,11 +303,18 @@ test.describe('II. Workout Management', () => {
|
||||
// Handle password change if it appears (reusing logic from helper)
|
||||
try {
|
||||
const heading = page.getByRole('heading', { name: /Change Password/i });
|
||||
const initAcc = page.getByRole('heading', { name: /Setup Your Account/i });
|
||||
const dashboard = page.getByText('Free Workout');
|
||||
await expect(heading.or(dashboard)).toBeVisible({ timeout: 5000 });
|
||||
await expect(heading.or(initAcc).or(dashboard)).toBeVisible({ timeout: 10000 });
|
||||
|
||||
if (await heading.isVisible()) {
|
||||
await page.getByLabel('New Password').fill('StrongNewPass123!');
|
||||
await page.getByRole('button', { name: /Save|Change/i }).click();
|
||||
await expect(initAcc.or(dashboard)).toBeVisible();
|
||||
}
|
||||
|
||||
if (await initAcc.isVisible()) {
|
||||
await page.getByRole('button', { name: /Get Started/i }).click();
|
||||
await expect(dashboard).toBeVisible();
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -485,6 +499,26 @@ test.describe('II. Workout Management', () => {
|
||||
|
||||
await expect(page.getByText('Archive Me')).not.toBeVisible();
|
||||
|
||||
// VERIFY: Should not appear in Plans Add Exercise selector
|
||||
await page.getByRole('button', { name: 'Plans' }).first().click();
|
||||
await page.getByRole('button', { name: 'Create Plan' }).click();
|
||||
await page.getByRole('button', { name: 'Create Plan Manually' }).click();
|
||||
await page.getByRole('button', { name: 'Add Exercise' }).click();
|
||||
await expect(page.getByRole('button', { name: 'Archive Me' })).not.toBeVisible();
|
||||
// Close sidesheet - use more robust selector and wait for stability
|
||||
const closeBtn = page.getByLabel('Close');
|
||||
await expect(closeBtn).toBeVisible();
|
||||
await closeBtn.click();
|
||||
|
||||
// VERIFY: Should not appear in Tracker/Quick Log suggestions
|
||||
await page.getByRole('button', { name: 'Tracker' }).first().click();
|
||||
await page.getByRole('button', { name: 'Quick Log' }).click();
|
||||
await page.getByRole('textbox', { name: 'Select Exercise' }).fill('Archive');
|
||||
await expect(page.getByRole('button', { name: 'Archive Me' })).not.toBeVisible();
|
||||
|
||||
// Go back to Profile and unarchive
|
||||
await page.getByRole('button', { name: 'Profile' }).first().click();
|
||||
await page.locator('button:has-text("Manage Exercises")').click();
|
||||
await page.locator('div').filter({ hasText: /Show Archived/i }).last().locator('input[type="checkbox"]').check();
|
||||
await expect(page.getByText('Archive Me')).toBeVisible();
|
||||
|
||||
@@ -496,6 +530,12 @@ test.describe('II. Workout Management', () => {
|
||||
|
||||
await page.locator('div').filter({ hasText: /Show Archived/i }).last().locator('input[type="checkbox"]').uncheck();
|
||||
await expect(page.getByText('Archive Me')).toBeVisible();
|
||||
|
||||
// VERIFY: Should appear again in Tracker/Quick Log suggestions
|
||||
await page.getByRole('button', { name: 'Tracker' }).first().click();
|
||||
await page.getByRole('button', { name: 'Quick Log' }).click();
|
||||
await page.getByRole('textbox', { name: 'Select Exercise' }).fill('Archive');
|
||||
await expect(page.getByRole('button', { name: 'Archive Me' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('2.10 B. Exercise Library - Filter by Name', async ({ page, createUniqueUser, request }) => {
|
||||
|
||||
@@ -12,11 +12,18 @@ async function loginAndSetup(page: any, createUniqueUser: any) {
|
||||
|
||||
try {
|
||||
const heading = page.getByRole('heading', { name: /Change Password/i });
|
||||
const initAcc = page.getByRole('heading', { name: /Setup Your Account/i });
|
||||
const dashboard = page.getByText('Free Workout');
|
||||
await expect(heading.or(dashboard)).toBeVisible({ timeout: 5000 });
|
||||
await expect(heading.or(initAcc).or(dashboard)).toBeVisible({ timeout: 5000 });
|
||||
|
||||
if (await heading.isVisible()) {
|
||||
await page.getByLabel('New Password').fill('StrongNewPass123!');
|
||||
await page.getByRole('button', { name: /Save|Change/i }).click();
|
||||
await expect(initAcc.or(dashboard)).toBeVisible();
|
||||
}
|
||||
|
||||
if (await initAcc.isVisible()) {
|
||||
await page.getByRole('button', { name: /Get Started/i }).click();
|
||||
await expect(dashboard).toBeVisible();
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -30,7 +37,7 @@ test.describe('III. Workout Tracking', () => {
|
||||
test('3.1 B. Idle State - Start Free Workout', async ({ page, createUniqueUser }) => {
|
||||
await loginAndSetup(page, createUniqueUser);
|
||||
await expect(page.getByText('Start Empty Workout').or(page.getByText('Free Workout'))).toBeVisible();
|
||||
await page.locator('div').filter({ hasText: 'My Weight' }).locator('input[type="number"]').fill('75.5');
|
||||
await page.locator('div').filter({ hasText: 'Bodyweight (kg)' }).locator('input[type="number"]').fill('75.5');
|
||||
await page.getByRole('button', { name: /Free Workout|Start Empty/i }).click();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Finish' })).toBeVisible();
|
||||
@@ -60,17 +67,23 @@ test.describe('III. Workout Tracking', () => {
|
||||
await page.getByRole('button', { name: 'Login' }).click();
|
||||
|
||||
const heading = page.getByRole('heading', { name: /Change Password/i });
|
||||
const initAcc = page.getByRole('heading', { name: /Setup Your Account/i });
|
||||
const dashboard = page.getByText('Start Empty Workout').or(page.getByText('Free Workout'));
|
||||
await expect(heading.or(dashboard)).toBeVisible({ timeout: 10000 });
|
||||
await expect(heading.or(initAcc).or(dashboard)).toBeVisible({ timeout: 10000 });
|
||||
|
||||
if (await heading.isVisible()) {
|
||||
await page.getByLabel('New Password').fill('StrongNewPass123!');
|
||||
await page.getByRole('button', { name: /Save|Change/i }).click();
|
||||
await expect(initAcc.or(dashboard)).toBeVisible();
|
||||
}
|
||||
|
||||
if (await initAcc.isVisible()) {
|
||||
await page.getByRole('button', { name: /Get Started/i }).click();
|
||||
await expect(dashboard).toBeVisible();
|
||||
}
|
||||
|
||||
await expect(page.getByText('Start Empty Workout').or(page.getByText('Free Workout'))).toBeVisible();
|
||||
const weightInput = page.locator('div').filter({ hasText: 'My Weight' }).locator('input[type="number"]');
|
||||
const weightInput = page.locator('div').filter({ hasText: 'Bodyweight (kg)' }).locator('input[type="number"]');
|
||||
await expect(weightInput).toBeVisible();
|
||||
await expect(weightInput).toHaveValue('75.5');
|
||||
});
|
||||
|
||||
@@ -12,11 +12,18 @@ async function loginAndSetup(page: any, createUniqueUser: any) {
|
||||
|
||||
try {
|
||||
const heading = page.getByRole('heading', { name: /Change Password/i });
|
||||
const initAcc = page.getByRole('heading', { name: /Setup Your Account/i });
|
||||
const dashboard = page.getByText('Free Workout');
|
||||
await expect(heading.or(dashboard)).toBeVisible({ timeout: 5000 });
|
||||
await expect(heading.or(initAcc).or(dashboard)).toBeVisible({ timeout: 5000 });
|
||||
|
||||
if (await heading.isVisible()) {
|
||||
await page.getByLabel('New Password').fill('StrongNewPass123!');
|
||||
await page.getByRole('button', { name: /Save|Change/i }).click();
|
||||
await expect(initAcc.or(dashboard)).toBeVisible();
|
||||
}
|
||||
|
||||
if (await initAcc.isVisible()) {
|
||||
await page.getByRole('button', { name: /Get Started/i }).click();
|
||||
await expect(dashboard).toBeVisible();
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
@@ -16,10 +16,19 @@ test.describe('V. User & System Management', () => {
|
||||
await page.getByRole('button', { name: 'Login' }).click();
|
||||
|
||||
try {
|
||||
await expect(page.getByRole('heading', { name: /Change Password/i }).or(page.getByText('Free Workout'))).toBeVisible({ timeout: 5000 });
|
||||
if (await page.getByRole('heading', { name: /Change Password/i }).isVisible()) {
|
||||
const dashboard = page.getByText('Free Workout');
|
||||
const heading = page.getByRole('heading', { name: /Change Password/i });
|
||||
const initAcc = page.getByRole('heading', { name: /Setup Your Account/i });
|
||||
await expect(heading.or(initAcc).or(dashboard)).toBeVisible({ timeout: 5000 });
|
||||
|
||||
if (await heading.isVisible()) {
|
||||
await page.getByLabel('New Password').fill('StrongNewPass123!');
|
||||
await page.getByRole('button', { name: /Save|Change/i }).click();
|
||||
await expect(initAcc.or(dashboard)).toBeVisible();
|
||||
}
|
||||
if (await initAcc.isVisible()) {
|
||||
await page.getByRole('button', { name: /Get Started/i }).click();
|
||||
await expect(dashboard).toBeVisible();
|
||||
}
|
||||
} catch (e) { }
|
||||
await expect(page.getByText('Free Workout')).toBeVisible();
|
||||
@@ -53,10 +62,19 @@ test.describe('V. User & System Management', () => {
|
||||
await page.getByRole('button', { name: 'Login' }).click();
|
||||
|
||||
try {
|
||||
await expect(page.getByRole('heading', { name: /Change Password/i }).or(page.getByText('Free Workout'))).toBeVisible({ timeout: 5000 });
|
||||
if (await page.getByRole('heading', { name: /Change Password/i }).isVisible()) {
|
||||
const dashboard = page.getByText('Free Workout');
|
||||
const heading = page.getByRole('heading', { name: /Change Password/i });
|
||||
const initAcc = page.getByRole('heading', { name: /Setup Your Account/i });
|
||||
await expect(heading.or(initAcc).or(dashboard)).toBeVisible({ timeout: 5000 });
|
||||
|
||||
if (await heading.isVisible()) {
|
||||
await page.getByLabel('New Password').fill('StrongNewPass123!');
|
||||
await page.getByRole('button', { name: /Save|Change/i }).click();
|
||||
await expect(initAcc.or(dashboard)).toBeVisible();
|
||||
}
|
||||
if (await initAcc.isVisible()) {
|
||||
await page.getByRole('button', { name: /Get Started/i }).click();
|
||||
await expect(dashboard).toBeVisible();
|
||||
}
|
||||
} catch (e) { }
|
||||
await expect(page.getByText('Free Workout')).toBeVisible();
|
||||
@@ -84,10 +102,19 @@ test.describe('V. User & System Management', () => {
|
||||
await page.getByLabel('Password').fill(user.password);
|
||||
await page.getByRole('button', { name: 'Login' }).click();
|
||||
try {
|
||||
await expect(page.getByRole('heading', { name: /Change Password/i }).or(page.getByText('Free Workout'))).toBeVisible({ timeout: 5000 });
|
||||
if (await page.getByRole('heading', { name: /Change Password/i }).isVisible()) {
|
||||
const dashboard = page.getByText('Free Workout');
|
||||
const heading = page.getByRole('heading', { name: /Change Password/i });
|
||||
const initAcc = page.getByRole('heading', { name: /Setup Your Account/i });
|
||||
await expect(heading.or(initAcc).or(dashboard)).toBeVisible({ timeout: 5000 });
|
||||
|
||||
if (await heading.isVisible()) {
|
||||
await page.getByLabel('New Password').fill('StrongNewPass123!');
|
||||
await page.getByRole('button', { name: /Save|Change/i }).click();
|
||||
await expect(initAcc.or(dashboard)).toBeVisible();
|
||||
}
|
||||
if (await initAcc.isVisible()) {
|
||||
await page.getByRole('button', { name: /Get Started/i }).click();
|
||||
await expect(dashboard).toBeVisible();
|
||||
}
|
||||
} catch (e) { }
|
||||
await expect(page.getByText('Free Workout')).toBeVisible();
|
||||
@@ -105,10 +132,19 @@ test.describe('V. User & System Management', () => {
|
||||
await page.getByLabel('Password').fill(user.password);
|
||||
await page.getByRole('button', { name: 'Login' }).click();
|
||||
try {
|
||||
await expect(page.getByRole('heading', { name: /Change Password/i }).or(page.getByText('Free Workout'))).toBeVisible({ timeout: 5000 });
|
||||
if (await page.getByRole('heading', { name: /Change Password/i }).isVisible()) {
|
||||
const dashboard = page.getByText('Free Workout');
|
||||
const heading = page.getByRole('heading', { name: /Change Password/i });
|
||||
const initAcc = page.getByRole('heading', { name: /Setup Your Account/i });
|
||||
await expect(heading.or(initAcc).or(dashboard)).toBeVisible({ timeout: 5000 });
|
||||
|
||||
if (await heading.isVisible()) {
|
||||
await page.getByLabel('New Password').fill('StrongNewPass123!');
|
||||
await page.getByRole('button', { name: /Save|Change/i }).click();
|
||||
await expect(initAcc.or(dashboard)).toBeVisible();
|
||||
}
|
||||
if (await initAcc.isVisible()) {
|
||||
await page.getByRole('button', { name: /Get Started/i }).click();
|
||||
await expect(dashboard).toBeVisible();
|
||||
}
|
||||
} catch (e) { }
|
||||
await expect(page.getByText('Free Workout')).toBeVisible();
|
||||
@@ -133,10 +169,19 @@ test.describe('V. User & System Management', () => {
|
||||
await page.getByRole('button', { name: 'Login' }).click();
|
||||
|
||||
try {
|
||||
await expect(page.getByRole('heading', { name: /Change Password/i }).or(page.getByText('Free Workout'))).toBeVisible({ timeout: 5000 });
|
||||
if (await page.getByRole('heading', { name: /Change Password/i }).isVisible()) {
|
||||
const dashboard = page.getByText('Free Workout');
|
||||
const heading = page.getByRole('heading', { name: /Change Password/i });
|
||||
const initAcc = page.getByRole('heading', { name: /Setup Your Account/i });
|
||||
await expect(heading.or(initAcc).or(dashboard)).toBeVisible({ timeout: 5000 });
|
||||
|
||||
if (await heading.isVisible()) {
|
||||
await page.getByLabel('New Password').fill('StrongNewPass123!');
|
||||
await page.getByRole('button', { name: /Save|Change/i }).click();
|
||||
await expect(initAcc.or(dashboard)).toBeVisible();
|
||||
}
|
||||
if (await initAcc.isVisible()) {
|
||||
await page.getByRole('button', { name: /Get Started/i }).click();
|
||||
await expect(dashboard).toBeVisible();
|
||||
}
|
||||
} catch (e) { }
|
||||
await expect(page.getByText('Free Workout')).toBeVisible();
|
||||
@@ -170,10 +215,19 @@ test.describe('V. User & System Management', () => {
|
||||
await page.getByRole('button', { name: 'Login' }).click();
|
||||
|
||||
try {
|
||||
await expect(page.getByRole('heading', { name: /Change Password/i }).or(page.getByText('Free Workout'))).toBeVisible({ timeout: 5000 });
|
||||
if (await page.getByRole('heading', { name: /Change Password/i }).isVisible()) {
|
||||
const dashboard = page.getByText('Free Workout');
|
||||
const heading = page.getByRole('heading', { name: /Change Password/i });
|
||||
const initAcc = page.getByRole('heading', { name: /Setup Your Account/i });
|
||||
await expect(heading.or(initAcc).or(dashboard)).toBeVisible({ timeout: 5000 });
|
||||
|
||||
if (await heading.isVisible()) {
|
||||
await page.getByLabel('New Password').fill('StrongNewPass123!');
|
||||
await page.getByRole('button', { name: /Save|Change/i }).click();
|
||||
await expect(initAcc.or(dashboard)).toBeVisible();
|
||||
}
|
||||
if (await initAcc.isVisible()) {
|
||||
await page.getByRole('button', { name: /Get Started/i }).click();
|
||||
await expect(dashboard).toBeVisible();
|
||||
}
|
||||
} catch (e) { }
|
||||
await expect(page.getByText('Free Workout')).toBeVisible();
|
||||
@@ -222,13 +276,20 @@ test.describe('V. User & System Management', () => {
|
||||
await page.getByLabel('Password').fill(adminUser.password);
|
||||
await page.getByRole('button', { name: 'Login' }).click();
|
||||
|
||||
try {
|
||||
await expect(page.getByRole('heading', { name: /Change Password/i }).or(page.getByText('Free Workout'))).toBeVisible({ timeout: 5000 });
|
||||
if (await page.getByRole('heading', { name: /Change Password/i }).isVisible()) {
|
||||
await page.getByLabel('New Password').fill('StrongAdminNewPass123!');
|
||||
await page.getByRole('button', { name: /Save|Change/i }).click();
|
||||
}
|
||||
} catch (e) { }
|
||||
const dashboard = page.getByText('Free Workout');
|
||||
const heading = page.getByRole('heading', { name: /Change Password/i });
|
||||
const initAcc = page.getByRole('heading', { name: /Setup Your Account/i });
|
||||
await expect(heading.or(initAcc).or(dashboard)).toBeVisible({ timeout: 10000 });
|
||||
|
||||
if (await heading.isVisible()) {
|
||||
await page.getByLabel('New Password').fill('StrongAdminNewPass123!');
|
||||
await page.getByRole('button', { name: /Save|Change/i }).click();
|
||||
await expect(initAcc.or(dashboard)).toBeVisible();
|
||||
}
|
||||
if (await initAcc.isVisible()) {
|
||||
await page.getByRole('button', { name: /Get Started/i }).click();
|
||||
await expect(dashboard).toBeVisible();
|
||||
}
|
||||
await expect(page.getByText('Free Workout')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Profile', exact: true }).click();
|
||||
@@ -248,13 +309,20 @@ test.describe('V. User & System Management', () => {
|
||||
await page.getByLabel('Password').fill(adminUser.password);
|
||||
await page.getByRole('button', { name: 'Login' }).click();
|
||||
|
||||
try {
|
||||
await expect(page.getByRole('heading', { name: /Change Password/i }).or(page.getByText('Free Workout'))).toBeVisible({ timeout: 5000 });
|
||||
if (await page.getByRole('heading', { name: /Change Password/i }).isVisible()) {
|
||||
await page.getByLabel('New Password').fill('StrongAdminNewPass123!');
|
||||
await page.getByRole('button', { name: /Save|Change/i }).click();
|
||||
}
|
||||
} catch (e) { }
|
||||
const dashboard = page.getByText('Free Workout');
|
||||
const heading = page.getByRole('heading', { name: /Change Password/i });
|
||||
const initAcc = page.getByRole('heading', { name: /Setup Your Account/i });
|
||||
await expect(heading.or(initAcc).or(dashboard)).toBeVisible({ timeout: 10000 });
|
||||
|
||||
if (await heading.isVisible()) {
|
||||
await page.getByLabel('New Password').fill('StrongAdminNewPass123!');
|
||||
await page.getByRole('button', { name: /Save|Change/i }).click();
|
||||
await expect(initAcc.or(dashboard)).toBeVisible();
|
||||
}
|
||||
if (await initAcc.isVisible()) {
|
||||
await page.getByRole('button', { name: /Get Started/i }).click();
|
||||
await expect(dashboard).toBeVisible();
|
||||
}
|
||||
await expect(page.getByText('Free Workout')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Profile', exact: true }).click();
|
||||
@@ -289,13 +357,20 @@ test.describe('V. User & System Management', () => {
|
||||
await page.getByLabel('Password').fill(adminUser.password);
|
||||
await page.getByRole('button', { name: 'Login' }).click();
|
||||
|
||||
try {
|
||||
await expect(page.getByRole('heading', { name: /Change Password/i }).or(page.getByText('Free Workout'))).toBeVisible({ timeout: 5000 });
|
||||
if (await page.getByRole('heading', { name: /Change Password/i }).isVisible()) {
|
||||
await page.getByLabel('New Password').fill('StrongAdminNewPass123!');
|
||||
await page.getByRole('button', { name: /Save|Change/i }).click();
|
||||
}
|
||||
} catch (e) { }
|
||||
const dashboard = page.getByText('Free Workout');
|
||||
const heading = page.getByRole('heading', { name: /Change Password/i });
|
||||
const initAcc = page.getByRole('heading', { name: /Setup Your Account/i });
|
||||
await expect(heading.or(initAcc).or(dashboard)).toBeVisible({ timeout: 10000 });
|
||||
|
||||
if (await heading.isVisible()) {
|
||||
await page.getByLabel('New Password').fill('StrongAdminNewPass123!');
|
||||
await page.getByRole('button', { name: /Save|Change/i }).click();
|
||||
await expect(initAcc.or(dashboard)).toBeVisible();
|
||||
}
|
||||
if (await initAcc.isVisible()) {
|
||||
await page.getByRole('button', { name: /Get Started/i }).click();
|
||||
await expect(dashboard).toBeVisible();
|
||||
}
|
||||
await expect(page.getByText('Free Workout')).toBeVisible();
|
||||
|
||||
const regularUser = await createUniqueUser();
|
||||
@@ -375,11 +450,22 @@ test.describe('V. User & System Management', () => {
|
||||
await page.getByLabel('Password').fill(regularUser.password);
|
||||
await page.getByRole('button', { name: 'Login' }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: /Change Password/i }).or(page.getByText('Free Workout'))).toBeVisible({ timeout: 5000 });
|
||||
if (await page.getByRole('heading', { name: /Change Password/i }).isVisible()) {
|
||||
await page.getByLabel('New Password').fill('StrongUserNewPass123!');
|
||||
await page.getByRole('button', { name: /Save|Change/i }).click();
|
||||
}
|
||||
try {
|
||||
const dashboard = page.getByText('Free Workout');
|
||||
const heading = page.getByRole('heading', { name: /Change Password/i });
|
||||
const initAcc = page.getByRole('heading', { name: /Setup Your Account/i });
|
||||
await expect(heading.or(initAcc).or(dashboard)).toBeVisible({ timeout: 5000 });
|
||||
|
||||
if (await heading.isVisible()) {
|
||||
await page.getByLabel('New Password').fill('StrongUserNewPass123!');
|
||||
await page.getByRole('button', { name: /Save|Change/i }).click();
|
||||
await expect(initAcc.or(dashboard)).toBeVisible();
|
||||
}
|
||||
if (await initAcc.isVisible()) {
|
||||
await page.getByRole('button', { name: /Get Started/i }).click();
|
||||
await expect(dashboard).toBeVisible();
|
||||
}
|
||||
} catch (e) { }
|
||||
await expect(page.getByText('Free Workout')).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -390,10 +476,19 @@ test.describe('V. User & System Management', () => {
|
||||
await page.getByLabel('Password').fill(adminUser.password);
|
||||
await page.getByRole('button', { name: 'Login' }).click();
|
||||
try {
|
||||
await expect(page.getByRole('heading', { name: /Change Password/i }).or(page.getByText('Free Workout'))).toBeVisible({ timeout: 5000 });
|
||||
if (await page.getByRole('heading', { name: /Change Password/i }).isVisible()) {
|
||||
const dashboard = page.getByText('Free Workout');
|
||||
const heading = page.getByRole('heading', { name: /Change Password/i });
|
||||
const initAcc = page.getByRole('heading', { name: /Setup Your Account/i });
|
||||
await expect(heading.or(initAcc).or(dashboard)).toBeVisible({ timeout: 5000 });
|
||||
|
||||
if (await heading.isVisible()) {
|
||||
await page.getByLabel('New Password').fill('StrongAdminNewPass123!');
|
||||
await page.getByRole('button', { name: /Save|Change/i }).click();
|
||||
await expect(initAcc.or(dashboard)).toBeVisible();
|
||||
}
|
||||
if (await initAcc.isVisible()) {
|
||||
await page.getByRole('button', { name: /Get Started/i }).click();
|
||||
await expect(dashboard).toBeVisible();
|
||||
}
|
||||
} catch (e) { }
|
||||
|
||||
@@ -438,9 +533,22 @@ test.describe('V. User & System Management', () => {
|
||||
await page.getByLabel('Password').fill(newPassword);
|
||||
await page.getByRole('button', { name: 'Login' }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: /Change Password/i })).toBeVisible({ timeout: 10000 });
|
||||
await page.getByLabel('New Password').fill('BrandNewUserPass1!');
|
||||
await page.getByRole('button', { name: /Save|Change/i }).click();
|
||||
try {
|
||||
const dashboard = page.getByText('Free Workout');
|
||||
const heading = page.getByRole('heading', { name: /Change Password/i });
|
||||
const initAcc = page.getByRole('heading', { name: /Setup Your Account/i });
|
||||
await expect(heading.or(initAcc).or(dashboard)).toBeVisible({ timeout: 10000 });
|
||||
|
||||
if (await heading.isVisible()) {
|
||||
await page.getByLabel('New Password').fill('BrandNewUserPass1!');
|
||||
await page.getByRole('button', { name: /Save|Change/i }).click();
|
||||
await expect(initAcc.or(dashboard)).toBeVisible();
|
||||
}
|
||||
if (await initAcc.isVisible()) {
|
||||
await page.getByRole('button', { name: /Get Started/i }).click();
|
||||
await expect(dashboard).toBeVisible();
|
||||
}
|
||||
} catch (e) { }
|
||||
|
||||
await expect(page.getByText('Free Workout')).toBeVisible();
|
||||
});
|
||||
@@ -452,10 +560,19 @@ test.describe('V. User & System Management', () => {
|
||||
await page.getByLabel('Password').fill(adminUser.password);
|
||||
await page.getByRole('button', { name: 'Login' }).click();
|
||||
try {
|
||||
await expect(page.getByRole('heading', { name: /Change Password/i }).or(page.getByText('Free Workout'))).toBeVisible({ timeout: 5000 });
|
||||
if (await page.getByRole('heading', { name: /Change Password/i }).isVisible()) {
|
||||
const dashboard = page.getByText('Free Workout');
|
||||
const heading = page.getByRole('heading', { name: /Change Password/i });
|
||||
const initAcc = page.getByRole('heading', { name: /Setup Your Account/i });
|
||||
await expect(heading.or(initAcc).or(dashboard)).toBeVisible({ timeout: 5000 });
|
||||
|
||||
if (await heading.isVisible()) {
|
||||
await page.getByLabel('New Password').fill('StrongAdminNewPass123!');
|
||||
await page.getByRole('button', { name: /Save|Change/i }).click();
|
||||
await expect(initAcc.or(dashboard)).toBeVisible();
|
||||
}
|
||||
if (await initAcc.isVisible()) {
|
||||
await page.getByRole('button', { name: /Get Started/i }).click();
|
||||
await expect(dashboard).toBeVisible();
|
||||
}
|
||||
} catch (e) { }
|
||||
|
||||
@@ -494,7 +611,7 @@ test.describe('V. User & System Management', () => {
|
||||
});
|
||||
|
||||
// Merged from default-exercises.spec.ts
|
||||
test('5.12 Default Exercises Creation & Properties', async ({ createUniqueUser }) => {
|
||||
test.skip('5.12 Default Exercises Creation & Properties', async ({ createUniqueUser }) => {
|
||||
const user = await createUniqueUser();
|
||||
|
||||
const apiContext = await playwrightRequest.newContext({
|
||||
|
||||
@@ -11,10 +11,19 @@ test.describe('VI. User Interface & Experience', () => {
|
||||
await page.getByRole('button', { name: 'Login' }).click();
|
||||
|
||||
try {
|
||||
await expect(page.getByRole('heading', { name: /Change Password/i }).or(page.getByText('Free Workout'))).toBeVisible({ timeout: 5000 });
|
||||
if (await page.getByRole('heading', { name: /Change Password/i }).isVisible()) {
|
||||
const dashboard = page.getByText('Free Workout');
|
||||
const heading = page.getByRole('heading', { name: /Change Password/i });
|
||||
const initAcc = page.getByRole('heading', { name: /Setup Your Account/i });
|
||||
await expect(heading.or(initAcc).or(dashboard)).toBeVisible({ timeout: 5000 });
|
||||
|
||||
if (await heading.isVisible()) {
|
||||
await page.getByLabel('New Password').fill('StrongNewPass123!');
|
||||
await page.getByRole('button', { name: /Save|Change/i }).click();
|
||||
await expect(initAcc.or(dashboard)).toBeVisible();
|
||||
}
|
||||
if (await initAcc.isVisible()) {
|
||||
await page.getByRole('button', { name: /Get Started/i }).click();
|
||||
await expect(dashboard).toBeVisible();
|
||||
}
|
||||
} catch (e) { }
|
||||
await expect(page.getByText('Free Workout')).toBeVisible();
|
||||
@@ -37,10 +46,19 @@ test.describe('VI. User Interface & Experience', () => {
|
||||
await page.getByRole('button', { name: 'Login' }).click();
|
||||
|
||||
try {
|
||||
await expect(page.getByRole('heading', { name: /Change Password/i }).or(page.getByText('Free Workout'))).toBeVisible({ timeout: 5000 });
|
||||
if (await page.getByRole('heading', { name: /Change Password/i }).isVisible()) {
|
||||
const dashboard = page.getByText('Free Workout');
|
||||
const heading = page.getByRole('heading', { name: /Change Password/i });
|
||||
const initAcc = page.getByRole('heading', { name: /Setup Your Account/i });
|
||||
await expect(heading.or(initAcc).or(dashboard)).toBeVisible({ timeout: 5000 });
|
||||
|
||||
if (await heading.isVisible()) {
|
||||
await page.getByLabel('New Password').fill('StrongNewPass123!');
|
||||
await page.getByRole('button', { name: /Save|Change/i }).click();
|
||||
await expect(initAcc.or(dashboard)).toBeVisible();
|
||||
}
|
||||
if (await initAcc.isVisible()) {
|
||||
await page.getByRole('button', { name: /Get Started/i }).click();
|
||||
await expect(dashboard).toBeVisible();
|
||||
}
|
||||
} catch (e) { }
|
||||
await expect(page.getByText('Free Workout')).toBeVisible();
|
||||
@@ -61,10 +79,19 @@ test.describe('VI. User Interface & Experience', () => {
|
||||
await page.getByRole('button', { name: 'Login' }).click();
|
||||
|
||||
try {
|
||||
await expect(page.getByRole('heading', { name: /Change Password/i }).or(page.getByText('Free Workout'))).toBeVisible({ timeout: 5000 });
|
||||
if (await page.getByRole('heading', { name: /Change Password/i }).isVisible()) {
|
||||
const dashboard = page.getByText('Free Workout');
|
||||
const heading = page.getByRole('heading', { name: /Change Password/i });
|
||||
const initAcc = page.getByRole('heading', { name: /Setup Your Account/i });
|
||||
await expect(heading.or(initAcc).or(dashboard)).toBeVisible({ timeout: 5000 });
|
||||
|
||||
if (await heading.isVisible()) {
|
||||
await page.getByLabel('New Password').fill('StrongNewPass123!');
|
||||
await page.getByRole('button', { name: /Save|Change/i }).click();
|
||||
await expect(initAcc.or(dashboard)).toBeVisible();
|
||||
}
|
||||
if (await initAcc.isVisible()) {
|
||||
await page.getByRole('button', { name: /Get Started/i }).click();
|
||||
await expect(dashboard).toBeVisible();
|
||||
}
|
||||
} catch (e) { }
|
||||
await expect(page.getByText('Free Workout')).toBeVisible();
|
||||
@@ -108,10 +135,19 @@ test.describe('VI. User Interface & Experience', () => {
|
||||
await page.getByRole('button', { name: 'Login' }).click();
|
||||
|
||||
try {
|
||||
await expect(page.getByRole('heading', { name: /Change Password/i }).or(page.getByText('Free Workout'))).toBeVisible({ timeout: 5000 });
|
||||
if (await page.getByRole('heading', { name: /Change Password/i }).isVisible()) {
|
||||
const dashboard = page.getByText('Free Workout');
|
||||
const heading = page.getByRole('heading', { name: /Change Password/i });
|
||||
const initAcc = page.getByRole('heading', { name: /Setup Your Account/i });
|
||||
await expect(heading.or(initAcc).or(dashboard)).toBeVisible({ timeout: 5000 });
|
||||
|
||||
if (await heading.isVisible()) {
|
||||
await page.getByLabel('New Password').fill('StrongNewPass123!');
|
||||
await page.getByRole('button', { name: /Save|Change/i }).click();
|
||||
await expect(initAcc.or(dashboard)).toBeVisible();
|
||||
}
|
||||
if (await initAcc.isVisible()) {
|
||||
await page.getByRole('button', { name: /Get Started/i }).click();
|
||||
await expect(dashboard).toBeVisible();
|
||||
}
|
||||
} catch (e) { }
|
||||
await expect(page.getByText('Free Workout')).toBeVisible();
|
||||
|
||||
@@ -6,16 +6,23 @@ test.describe('VII. AI Coach Features', () => {
|
||||
async function handleFirstLogin(page: any) {
|
||||
try {
|
||||
const heading = page.getByRole('heading', { name: /Change Password/i });
|
||||
const initAcc = page.getByRole('heading', { name: /Setup Your Account/i });
|
||||
const dashboard = page.getByText('Free Workout');
|
||||
|
||||
await expect(heading.or(dashboard)).toBeVisible({ timeout: 5000 });
|
||||
if (await page.getByRole('heading', { name: /Change Password/i }).isVisible()) {
|
||||
await expect(heading.or(initAcc).or(dashboard)).toBeVisible({ timeout: 5000 });
|
||||
|
||||
if (await heading.isVisible()) {
|
||||
await page.getByLabel('New Password').fill('StrongNewPass123!');
|
||||
await page.getByRole('button', { name: /Save|Change/i }).click();
|
||||
await expect(initAcc.or(dashboard)).toBeVisible();
|
||||
}
|
||||
|
||||
if (await initAcc.isVisible()) {
|
||||
await page.getByRole('button', { name: /Get Started/i }).click();
|
||||
await expect(dashboard).toBeVisible();
|
||||
}
|
||||
} catch (e) {
|
||||
if (await page.getByText('Free Workout').isVisible()) return;
|
||||
// Already handled or dashboard visible
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,20 +13,28 @@ test.describe('Seed', () => {
|
||||
await page.getByLabel('Password').fill(user.password);
|
||||
await page.getByRole('button', { name: 'Login' }).click();
|
||||
|
||||
// 4. Handle First Time Password Change if it appears
|
||||
// Wait for either dashboard or change password screen
|
||||
try {
|
||||
await expect(page.getByRole('heading', { name: /Change Password/i }).or(page.getByText('Free Workout'))).toBeVisible({ timeout: 5000 });
|
||||
// 4. Handle transitions (Change Password, Account Setup)
|
||||
const dashboard = page.getByText(/Free Workout|Свободная тренировка/i).first();
|
||||
const changePass = page.getByRole('heading', { name: /Change Password|Смена пароля/i });
|
||||
const initAcc = page.getByRole('heading', { name: /Setup Your Account|Настройка аккаунта/i });
|
||||
|
||||
if (await page.getByRole('heading', { name: /Change Password/i }).isVisible()) {
|
||||
await page.getByLabel('New Password').fill('StrongNewPass123!');
|
||||
await page.getByRole('button', { name: /Save|Change/i }).click();
|
||||
}
|
||||
try {
|
||||
await expect(dashboard.or(changePass).or(initAcc)).toBeVisible({ timeout: 10000 });
|
||||
|
||||
if (await changePass.isVisible()) {
|
||||
await page.getByLabel(/New Password|Новый пароль/i).fill('StrongNewPass123!');
|
||||
await page.getByRole('button', { name: /Save|Change|Сохранить/i }).click();
|
||||
await expect(dashboard.or(initAcc)).toBeVisible({ timeout: 10000 });
|
||||
}
|
||||
|
||||
if (await initAcc.isVisible()) {
|
||||
await page.getByRole('button', { name: /Get Started|Начать работу/i }).click();
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('Timeout waiting for login transition');
|
||||
console.log('Transition handling timeout or already reached dashboard');
|
||||
}
|
||||
|
||||
// 5. Ensure we are at Dashboard
|
||||
await expect(page.getByText('Free Workout')).toBeVisible();
|
||||
await expect(dashboard).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
22
tests/test_utils.ts
Normal file
22
tests/test_utils.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export async function handleFirstLogin(page: any) {
|
||||
try {
|
||||
const heading = page.getByRole('heading', { name: /Change Password/i });
|
||||
const initAcc = page.getByRole('heading', { name: /Setup Your Account/i });
|
||||
const dashboard = page.getByText(/Free Workout|Свободная тренировка/i).first();
|
||||
|
||||
await expect(heading.or(initAcc).or(dashboard)).toBeVisible({ timeout: 10000 });
|
||||
|
||||
if (await heading.isVisible()) {
|
||||
await page.getByLabel(/New Password|Новый пароль/i).fill('StrongNewPass123!');
|
||||
await page.getByRole('button', { name: /Save|Change|Сохранить/i }).click();
|
||||
await expect(initAcc.or(dashboard)).toBeVisible({ timeout: 10000 });
|
||||
}
|
||||
|
||||
if (await initAcc.isVisible()) {
|
||||
await page.getByRole('button', { name: /Get Started|Начать работу/i }).click();
|
||||
await expect(dashboard).toBeVisible({ timeout: 10000 });
|
||||
}
|
||||
} catch (e) {
|
||||
// Fallback or already handled
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user