diff --git a/Dockerfile.api b/Dockerfile.api index 6e9798f..8ce089f 100644 --- a/Dockerfile.api +++ b/Dockerfile.api @@ -1,11 +1,12 @@ FROM node:22-slim -# Install sharp dependencies for Debian ARM +# Install sharp dependencies and procps for Debian ARM RUN apt-get update && apt-get install -y \ bash \ curl \ git \ libvips-dev \ + procps \ && rm -rf /var/lib/apt/lists/* WORKDIR /app diff --git a/backend/src/v1/auth/dto/UserWithoutPassword.dto.ts b/backend/src/v1/auth/dto/UserWithoutPassword.dto.ts index 6354499..c29afb8 100644 --- a/backend/src/v1/auth/dto/UserWithoutPassword.dto.ts +++ b/backend/src/v1/auth/dto/UserWithoutPassword.dto.ts @@ -20,6 +20,15 @@ export class UserWithoutPasswordDto { @ApiProperty({ default: true }) showRpe: boolean; + @ApiProperty({ default: 3 }) + weeklyWorkoutGoal: number; + + @ApiProperty({ default: 0 }) + currentStreak: number; + + @ApiProperty({ default: 0 }) + currentWeekWorkouts: number; + constructor(user: User) { this.id = user.id; this.email = user.email; @@ -27,5 +36,8 @@ export class UserWithoutPasswordDto { this.lastName = user.lastName; this.avatar = user.avatar; this.showRpe = user.showRpe ?? true; + this.weeklyWorkoutGoal = user.weeklyWorkoutGoal ?? 3; + this.currentStreak = user.currentStreak ?? 0; + this.currentWeekWorkouts = user.currentWeekWorkouts ?? 0; } } diff --git a/backend/src/v1/exercise/dto/exerciseResponse.dto.ts b/backend/src/v1/exercise/dto/exerciseResponse.dto.ts index f0f1182..50723ba 100644 --- a/backend/src/v1/exercise/dto/exerciseResponse.dto.ts +++ b/backend/src/v1/exercise/dto/exerciseResponse.dto.ts @@ -20,6 +20,12 @@ export class ExerciseResponseDto { }) isNameCustom?: boolean; + @ApiProperty({ + required: false, + description: 'If true, the user has customized this exercise after importing it', + }) + isCustomized?: boolean; + @ApiProperty({ required: false, description: diff --git a/backend/src/v1/exercise/exercise.service.ts b/backend/src/v1/exercise/exercise.service.ts index 8485119..4377340 100644 --- a/backend/src/v1/exercise/exercise.service.ts +++ b/backend/src/v1/exercise/exercise.service.ts @@ -23,6 +23,7 @@ export class ExerciseService { name: exercise.name, i18nKey: exercise.i18nKey, isNameCustom: exercise.isNameCustom, + isCustomized: exercise.isCustomized, globalExerciseId: exercise.globalExercise?.id, description: exercise.description, image: exercise.image, diff --git a/backend/src/v1/migrations/1768754686-AddStreakToUser.ts b/backend/src/v1/migrations/1768754686-AddStreakToUser.ts new file mode 100644 index 0000000..2bf0004 --- /dev/null +++ b/backend/src/v1/migrations/1768754686-AddStreakToUser.ts @@ -0,0 +1,52 @@ +import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; + +export class AddStreakToUser1768754686 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // Add weeklyWorkoutGoal column + await queryRunner.addColumn( + 'user', + new TableColumn({ + name: 'weeklyWorkoutGoal', + type: 'int', + default: 3, + }), + ); + + // Add currentStreak column + await queryRunner.addColumn( + 'user', + new TableColumn({ + name: 'currentStreak', + type: 'int', + default: 0, + }), + ); + + // Add lastStreakCheckDate column + await queryRunner.addColumn( + 'user', + new TableColumn({ + name: 'lastStreakCheckDate', + type: 'timestamp', + isNullable: true, + }), + ); + + // Add currentWeekWorkouts column + await queryRunner.addColumn( + 'user', + new TableColumn({ + name: 'currentWeekWorkouts', + type: 'int', + default: 0, + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropColumn('user', 'currentWeekWorkouts'); + await queryRunner.dropColumn('user', 'lastStreakCheckDate'); + await queryRunner.dropColumn('user', 'currentStreak'); + await queryRunner.dropColumn('user', 'weeklyWorkoutGoal'); + } +} diff --git a/backend/src/v1/user/dto/UpdateUser.dto.ts b/backend/src/v1/user/dto/UpdateUser.dto.ts index bf03aa0..d11cea9 100644 --- a/backend/src/v1/user/dto/UpdateUser.dto.ts +++ b/backend/src/v1/user/dto/UpdateUser.dto.ts @@ -1,4 +1,4 @@ -import { IsBoolean, IsOptional, IsString } from 'class-validator'; +import { IsBoolean, IsEmail, IsOptional, IsString, MinLength } from 'class-validator'; export class UpdateUserDto { @IsOptional() @@ -9,10 +9,23 @@ export class UpdateUserDto { @IsString() lastName?: string; + @IsOptional() + @IsEmail() + email?: string; + @IsOptional() @IsString() avatar?: string; + @IsOptional() + @IsString() + currentPassword?: string; + + @IsOptional() + @IsString() + @MinLength(8) + newPassword?: string; + @IsOptional() @IsBoolean() showRpe?: boolean; diff --git a/backend/src/v1/user/user.controller.ts b/backend/src/v1/user/user.controller.ts index 5072fc3..d11e285 100644 --- a/backend/src/v1/user/user.controller.ts +++ b/backend/src/v1/user/user.controller.ts @@ -90,7 +90,7 @@ export class UserController { @ApiOkResponse({ type: UserWithoutPasswordDto }) @UseInterceptors(FileInterceptor('file', { storage: undefined })) async uploadAvatar( - @UploadedFile() file: Express.Multer.File, + @UploadedFile() file: any, @Req() req: RequestWithUser, ): Promise { if (!req.user?.id) { @@ -110,4 +110,40 @@ export class UserController { return this.userService.updateAvatar(+req.user.id, avatarUrl); } + + @Get('streak') + @ApiOperation({ summary: 'Get user streak information' }) + @ApiOkResponse({ + description: 'User streak information', + schema: { + example: { + currentStreak: 5, + weeklyWorkoutGoal: 3, + currentWeekWorkouts: 2, + progressPercentage: 67, + }, + }, + }) + getStreak(@Req() req: RequestWithUser) { + if (!req.user?.id) { + throw new UnauthorizedException('User not authenticated'); + } + return this.userService.getStreakInfo(+req.user.id); + } + + @Put('weekly-goal') + @ApiOperation({ summary: 'Update weekly workout goal' }) + @ApiOkResponse({ type: UserWithoutPasswordDto }) + updateWeeklyGoal( + @Req() req: RequestWithUser, + @Body() body: { weeklyWorkoutGoal: number }, + ) { + if (!req.user?.id) { + throw new UnauthorizedException('User not authenticated'); + } + return this.userService.updateWeeklyWorkoutGoal( + +req.user.id, + body.weeklyWorkoutGoal, + ); + } } diff --git a/backend/src/v1/user/user.entity.ts b/backend/src/v1/user/user.entity.ts index 4da1963..d5535f7 100644 --- a/backend/src/v1/user/user.entity.ts +++ b/backend/src/v1/user/user.entity.ts @@ -32,6 +32,18 @@ export class User { @Column({ default: true }) showRpe: boolean; + @Column({ default: 3 }) + weeklyWorkoutGoal: number; + + @Column({ default: 0 }) + currentStreak: number; + + @Column({ type: 'timestamp', nullable: true }) + lastStreakCheckDate: Date; + + @Column({ default: 0 }) + currentWeekWorkouts: number; + @CreateDateColumn() createdAt: Date; diff --git a/backend/src/v1/user/user.service.ts b/backend/src/v1/user/user.service.ts index c3c9603..b44791b 100644 --- a/backend/src/v1/user/user.service.ts +++ b/backend/src/v1/user/user.service.ts @@ -1,4 +1,8 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { User } from './user.entity'; @@ -8,6 +12,7 @@ import { UpdateUserDto } from './dto/UpdateUser.dto'; import { Workout } from '../workout/workout.entity'; import { WorkoutSession } from '../workoutSession/workoutSession.entity'; import { UploadService } from '../upload/upload.service'; +import * as bcrypt from 'bcrypt'; @Injectable() export class UserService { @@ -43,13 +48,66 @@ export class UserService { userId: number, dto: UpdateUserDto, ): Promise { - const user = await this.userRepo.findOne({ where: { id: userId } }); + const needsPassword = !!dto.newPassword; + + const user = await this.userRepo.findOne({ + where: { id: userId }, + select: needsPassword + ? [ + 'id', + 'email', + 'firstName', + 'lastName', + 'avatar', + 'showRpe', + 'password', + 'createdAt', + 'updatedAt', + ] + : [ + 'id', + 'email', + 'firstName', + 'lastName', + 'avatar', + 'showRpe', + 'createdAt', + 'updatedAt', + ], + }); if (!user) { throw new NotFoundException('User not found'); } - Object.assign(user, dto); + if (dto.email && dto.email !== user.email) { + const existing = await this.userRepo.findOne({ + where: { email: dto.email }, + }); + if (existing && existing.id !== userId) { + throw new BadRequestException('Email already in use'); + } + user.email = dto.email; + } + + if (dto.newPassword) { + if (!dto.currentPassword) { + throw new BadRequestException('Current password is required'); + } + if (!user.password) { + throw new BadRequestException('Password not available for comparison'); + } + const ok = await bcrypt.compare(dto.currentPassword, user.password); + if (!ok) { + throw new BadRequestException('Current password is incorrect'); + } + user.password = await bcrypt.hash(dto.newPassword, 10); + } + + // Destructure to exclude sensitive fields before saving + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { currentPassword, newPassword, email, ...safeDto } = dto; + Object.assign(user, safeDto); const updated = await this.userRepo.save(user); return new UserWithoutPasswordDto(updated); @@ -92,4 +150,139 @@ export class UserService { return new UserWithoutPasswordDto(updated); } + + /** + * Update streak and weekly workout count when a workout is completed + * Should be called after finishing a workout session + */ + async updateStreakOnWorkoutCompletion(userId: number): Promise { + const user = await this.userRepo.findOne({ where: { id: userId } }); + if (!user) return; + + const now = new Date(); + + // Check if we need to reset for a new week + await this.checkAndResetWeeklyProgress(user, now); + + // Increment current week workouts + user.currentWeekWorkouts += 1; + + // Increment streak by 1 for every workout + user.currentStreak += 1; + + user.lastStreakCheckDate = now; + await this.userRepo.save(user); + } + + /** + * Check if we're in a new week and reset/update streak accordingly + */ + private async checkAndResetWeeklyProgress( + user: User, + now: Date, + ): Promise { + if (!user.lastStreakCheckDate) { + // First time tracking - reset counters + user.currentWeekWorkouts = 0; + user.currentStreak = 0; + return; + } + + const lastCheck = new Date(user.lastStreakCheckDate); + + // Get the Monday of each week (ISO week starts on Monday) + const getMondayOfWeek = (date: Date): Date => { + const d = new Date(date); + const day = d.getDay(); + const diff = day === 0 ? -6 : 1 - day; // Adjust when day is Sunday + d.setDate(d.getDate() + diff); + d.setHours(0, 0, 0, 0); + return d; + }; + + const lastWeekMonday = getMondayOfWeek(lastCheck); + const currentWeekMonday = getMondayOfWeek(now); + + // If we're in a new week + if (currentWeekMonday > lastWeekMonday) { + // Calculate how many weeks have passed + const weeksPassed = Math.floor( + (currentWeekMonday.getTime() - lastWeekMonday.getTime()) / + (7 * 24 * 60 * 60 * 1000), + ); + + // Check if user met their goal in the previous week + if (user.currentWeekWorkouts < user.weeklyWorkoutGoal) { + // Didn't meet goal, reset streak to 0 + user.currentStreak = 0; + } else if (weeksPassed > 1) { + // More than one week passed (means they didn't workout at all in between) + // Even if they met the goal in the last tracked week, they missed weeks in between + user.currentStreak = 0; + } + // If they met the goal and it's been exactly 1 week, streak continues + + // Reset weekly workout count for the new week + user.currentWeekWorkouts = 0; + } + } + + /** + * Get user's current streak information + */ + async getStreakInfo(userId: number): Promise<{ + currentStreak: number; + weeklyWorkoutGoal: number; + currentWeekWorkouts: number; + progressPercentage: number; + }> { + const user = await this.userRepo.findOne({ where: { id: userId } }); + if (!user) { + throw new NotFoundException('User not found'); + } + + // Check if we need to update streak for new week + const now = new Date(); + await this.checkAndResetWeeklyProgress(user, now); + await this.userRepo.save(user); + + const progressPercentage = + user.weeklyWorkoutGoal > 0 + ? Math.min( + (user.currentWeekWorkouts / user.weeklyWorkoutGoal) * 100, + 100, + ) + : 0; + + return { + currentStreak: user.currentStreak, + weeklyWorkoutGoal: user.weeklyWorkoutGoal, + currentWeekWorkouts: user.currentWeekWorkouts, + progressPercentage: Math.round(progressPercentage), + }; + } + + /** + * Update user's weekly workout goal + */ + async updateWeeklyWorkoutGoal( + userId: number, + weeklyWorkoutGoal: number, + ): Promise { + if (weeklyWorkoutGoal < 1 || weeklyWorkoutGoal > 7) { + throw new BadRequestException( + 'Weekly workout goal must be between 1 and 7', + ); + } + + const user = await this.userRepo.findOne({ where: { id: userId } }); + if (!user) { + throw new NotFoundException('User not found'); + } + + user.weeklyWorkoutGoal = weeklyWorkoutGoal; + const updated = await this.userRepo.save(user); + + return new UserWithoutPasswordDto(updated); + } } diff --git a/backend/src/v1/workoutSession/workoutSession.module.ts b/backend/src/v1/workoutSession/workoutSession.module.ts index 419a08d..e8f51b4 100644 --- a/backend/src/v1/workoutSession/workoutSession.module.ts +++ b/backend/src/v1/workoutSession/workoutSession.module.ts @@ -7,6 +7,7 @@ import { WorkoutSessionController } from './workoutSession.controller'; import { WorkoutSessionService } from './workoutSession.service'; import { WorkoutModule } from '../workout/workout.module'; import { ExerciseModule } from '../exercise/exercise.module'; +import { UserModule } from '../user/user.module'; @Module({ imports: [ @@ -16,6 +17,7 @@ import { ExerciseModule } from '../exercise/exercise.module'; WorkoutSessionSet, ]), forwardRef(() => WorkoutModule), + forwardRef(() => UserModule), ExerciseModule, ], providers: [WorkoutSessionService], diff --git a/backend/src/v1/workoutSession/workoutSession.service.ts b/backend/src/v1/workoutSession/workoutSession.service.ts index 694698f..541736e 100644 --- a/backend/src/v1/workoutSession/workoutSession.service.ts +++ b/backend/src/v1/workoutSession/workoutSession.service.ts @@ -11,6 +11,7 @@ import { WorkoutSessionExercise } from './workoutSessionExercise.entity'; import { WorkoutSessionSet } from './workoutSessionSet.entity'; import { Exercise } from '../exercise/exercise.entity'; import { WorkoutStatus } from '../types/WorkoutStatus.type'; +import { UserService } from '../user/user.service'; @Injectable() export class WorkoutSessionService { @@ -26,6 +27,7 @@ export class WorkoutSessionService { @InjectRepository(WorkoutSessionSet) private readonly setRepo: Repository, private readonly dataSource: DataSource, + private readonly userService: UserService, ) {} async getAllSessions(userId: number): Promise { @@ -278,6 +280,9 @@ export class WorkoutSessionService { await manager.save(WorkoutSession, session); + // Update user's streak after finishing workout + await this.userService.updateStreakOnWorkoutCompletion(userId); + return manager.findOneOrFail(WorkoutSession, { where: { id: session.id }, relations: ['exercises', 'exercises.sets', 'exercises.exercise'], diff --git a/frontend/.prettierrc b/frontend/.prettierrc new file mode 100644 index 0000000..dbd0df9 --- /dev/null +++ b/frontend/.prettierrc @@ -0,0 +1,8 @@ +{ + "semi": false, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "es5", + "printWidth": 100, + "arrowParens": "avoid" +} diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 242f229..9105a28 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -6,6 +6,7 @@ import pluginVue from 'eslint-plugin-vue' import vueTsEslintConfig from '@vue/eslint-config-typescript' +import eslintConfigPrettier from 'eslint-config-prettier' export default [ { @@ -21,6 +22,8 @@ export default [ ...pluginVue.configs['flat/recommended'], ...vueTsEslintConfig(), + eslintConfigPrettier, + { rules: { '@typescript-eslint/no-unused-expressions': [ @@ -31,6 +34,6 @@ export default [ }, ], 'vue/multi-word-component-names': 'off', - } - } -] \ No newline at end of file + }, + }, +] diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8b78c06..7648083 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "frontend", "version": "0.1.1", + "license": "MIT", "dependencies": { "@fontsource/roboto": "5.2.5", "@mdi/font": "7.4.47", @@ -15,6 +16,7 @@ "prettier-eslint": "^16.4.2", "vue": "^3.5.13", "vue-eslint-parser": "^10.1.3", + "vue-i18n": "^11.2.8", "vue-sonner": "^2.0.0", "vuetify": "^3.8.1", "vuetify-sonner": "^0.3.21" @@ -70,6 +72,7 @@ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.5.tgz", "integrity": "sha512-OsQd175SxWkGlzbny8J3K8TnnDD0N3lrIUtB92xwyRpzaenGZhxDvxN/JgU00U3CDZNj9tPuDJ5H0WS4Nt3vKg==", "license": "MIT", + "peer": true, "dependencies": { "@babel/types": "^7.27.3" }, @@ -798,6 +801,50 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@intlify/core-base": { + "version": "11.2.8", + "resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.2.8.tgz", + "integrity": "sha512-nBq6Y1tVkjIUsLsdOjDSJj4AsjvD0UG3zsg9Fyc+OivwlA/oMHSKooUy9tpKj0HqZ+NWFifweHavdljlBLTwdA==", + "license": "MIT", + "dependencies": { + "@intlify/message-compiler": "11.2.8", + "@intlify/shared": "11.2.8" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/@intlify/message-compiler": { + "version": "11.2.8", + "resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.2.8.tgz", + "integrity": "sha512-A5n33doOjmHsBtCN421386cG1tWp5rpOjOYPNsnpjIJbQ4POF0QY2ezhZR9kr0boKwaHjbOifvyQvHj2UTrDFQ==", + "license": "MIT", + "dependencies": { + "@intlify/shared": "11.2.8", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/@intlify/shared": { + "version": "11.2.8", + "resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.2.8.tgz", + "integrity": "sha512-l6e4NZyUgv8VyXXH4DbuucFOBmxLF56C/mqh2tvApbzl2Hrhi1aTDcuv5TKdxzfHYmpO3UB0Cz04fgDT9vszfw==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, "node_modules/@jest/schemas": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", @@ -1434,6 +1481,7 @@ "integrity": "sha512-jnVe5ULKl6tijxUhvQeNbQG/84fHfg+yMak02cT8QVhBx/F05rAVxCGBYYTh2EKz22D6JF5ktXuNwdx7b9iEGw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1474,6 +1522,7 @@ "integrity": "sha512-vxXJV1hVFx3IXz/oy2sICsJukaBrtDEQSBiV48/YIV5KWjX1dO+bcIr/kCPrW6weKXvsaGKFNlwH0v2eYdRRbA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.34.0", "@typescript-eslint/types": "8.34.0", @@ -1995,6 +2044,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2542,6 +2592,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.29.0.tgz", "integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==", "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -2652,6 +2703,7 @@ "integrity": "sha512-tl9s+KN3z0hN2b8fV2xSs5ytGl7Esk1oSCxULLwFcdaElhZ8btYYZFrWxvh4En+czrSDtuLCeCOGa8HhEZuBdQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "natural-compare": "^1.4.0", @@ -3985,6 +4037,7 @@ "integrity": "sha512-ttXO/InUULUXkMHpTdp9Fj4hLpD/2AoJdmAbAeW2yu1iy1k+pkFekQXw5VpC0/5p51IOR/jDaDRfRWRnMMsGOA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@vue/devtools-api": "^7.7.2" }, @@ -4303,6 +4356,7 @@ "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -5332,6 +5386,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5756,6 +5811,7 @@ "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -5847,6 +5903,7 @@ "integrity": "sha512-Pb7bKhQH8qPMzURmEGq2aIqCJkruFNsyf1NcrrtnjsOIkqJPMcBbiP0oJoO8/uAmyB5W/1JTbbUEsyXdMM0QHQ==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@vuetify/loader-shared": "^2.1.0", "debug": "^4.3.3", @@ -5873,6 +5930,7 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.16.tgz", "integrity": "sha512-rjOV2ecxMd5SiAmof2xzh2WxntRcigkX/He4YFJ6WdRvVUrbt6DxC1Iujh10XLl8xCDRDtGKMeO3D+pRQ1PP9w==", "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.16", "@vue/compiler-sfc": "3.5.16", @@ -5931,12 +5989,39 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/vue-i18n": { + "version": "11.2.8", + "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.2.8.tgz", + "integrity": "sha512-vJ123v/PXCZntd6Qj5Jumy7UBmIuE92VrtdX+AXr+1WzdBHojiBxnAxdfctUFL+/JIN+VQH4BhsfTtiGsvVObg==", + "license": "MIT", + "dependencies": { + "@intlify/core-base": "11.2.8", + "@intlify/shared": "11.2.8", + "@vue/devtools-api": "^6.5.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + }, + "peerDependencies": { + "vue": "^3.0.0" + } + }, + "node_modules/vue-i18n/node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, "node_modules/vue-router": { "version": "4.5.1", "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.5.1.tgz", "integrity": "sha512-ogAF3P97NPm8fJsE4by9dwSYtDwXIY1nFY9T6DyQnGHd1E2Da94w9JIolpe42LJGIl0DwOHBi8TcRPlPGwbTtw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vue/devtools-api": "^6.6.4" }, @@ -5982,6 +6067,7 @@ "resolved": "https://registry.npmjs.org/vuetify/-/vuetify-3.8.9.tgz", "integrity": "sha512-X9kCxeqf7w5sca2Mfn4NCVsDDimi81jxKyqsZHjW0XG/rTdtwRFKttxOcv0Mmi+67ulPjDZywA7pBFK0rxoafA==", "license": "MIT", + "peer": true, "engines": { "node": "^12.20 || >=14.13" }, diff --git a/frontend/package.json b/frontend/package.json index a9e8231..004119a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,8 +20,9 @@ "prettier-eslint": "^16.4.2", "vue": "^3.5.13", "vue-eslint-parser": "^10.1.3", + "vue-i18n": "^11.2.8", "vue-sonner": "^2.0.0", - "vuetify": "^3.8.1", + "vuetify": "^3.11.6", "vuetify-sonner": "^0.3.21" }, "devDependencies": { diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 68eb17f..19cafa6 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -31,7 +31,7 @@ mdi-chevron-up

- Resume workout + {{ $t('navigation.resumeWorkout') }}

diff --git a/frontend/src/components/Exercise/AddExerciseList.vue b/frontend/src/components/Exercise/AddExerciseList.vue index f6b7e7d..4d226a3 100644 --- a/frontend/src/components/Exercise/AddExerciseList.vue +++ b/frontend/src/components/Exercise/AddExerciseList.vue @@ -1,17 +1,17 @@ - Default Pause: + {{ $t('exerciseForm.defaultPause') }}: {{ selectedExercise.defaultPauseSeconds || 0 }} - seconds + {{ $t('units.sec') }} @@ -117,7 +117,7 @@ - Created at: + {{ $t('common.createdAt') }}: {{ new Date( @@ -138,7 +138,7 @@ :loading="isLoading" @click="updateExercise" > - Save Changes + {{ $t('common.saveChanges') }}
@@ -207,10 +213,10 @@ > - Delete Exercise + {{ $t('exerciseForm.deleteTitle') }} - Are you sure you want to delete this exercise? + {{ $t('exerciseForm.deleteConfirm') }} @@ -218,13 +224,13 @@ color="grey" @click="isDeleteExerciseOpen = false" > - Cancel + {{ $t('common.cancel') }} - Delete + {{ $t('common.delete') }} @@ -242,6 +248,7 @@ import { import { useMuscleGroupStore } from '@/stores/muscleGroup.store'; import { toast } from 'vuetify-sonner'; import ImageUpload from '@/components/basicUI/ImageUpload.vue'; +import { useI18n } from 'vue-i18n'; const props = defineProps<{ workoutId?: number; @@ -255,6 +262,7 @@ const exerciseStore = useExerciseStore(); const isLoading = ref(false); const isDeleteExerciseOpen = ref(false); const imageFile = ref(null); +const { t } = useI18n({ useScope: 'global' }); const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:8393/v1'; @@ -294,7 +302,7 @@ const removeExercise = async () => { response = await deleteExercise(props.selectedExercise?.id ?? 0); if (response) { - toast.success('Exercise removed successfully!', { progressBar: true }); + toast.success(t('exercise.deleted'), { progressBar: true, duration: 1000 }); await exerciseStore.setExercises(true); emit('close'); } else { @@ -302,6 +310,7 @@ const removeExercise = async () => { } } catch (error) { console.error('Error in removeExerciseFromWorkout:', error); + toast.error(t('exercise.removeError'), { progressBar: true, duration: 1000 }); } }; @@ -320,11 +329,11 @@ const updateExercise = async () => { try { isLoading.value = true; if (!editExercise.value) { - toast.error('No exercise data to update.'); + toast.error(t('exercise.updateNoData')); return; } if (!props.selectedExercise) { - toast.error('No selected exercise to update.'); + toast.error(t('exercise.updateNoSelected')); return; } const response = await updateExerciseInExercise( @@ -339,18 +348,18 @@ const updateExercise = async () => { imageFile.value = null; } catch (imageError) { console.error('Error uploading image:', imageError); - toast.warning('Exercise updated but image upload failed'); + toast.warning(t('exercise.updatedImageUploadFailed'), { progressBar: true, duration: 1000 }); } } - toast.success('Exercise updated successfully!', { progressBar: true }); + toast.success(t('exercise.updated'), { progressBar: true, duration: 1000 }); await exerciseStore.setExercises(true); isViewExercise.value = true; } else { - toast.error('Failed to update exercise.'); + toast.error(t('exercise.failedToUpdate'), { progressBar: true, duration: 1000 }); } } catch (error) { - toast.error('Error in updateExercise.'); + toast.error(t('exercise.updateError'), { progressBar: true, duration: 1000 }); console.error('Error in updateExercise:', error); } finally { isLoading.value = false; diff --git a/frontend/src/components/Exercise/ExerciseList.vue b/frontend/src/components/Exercise/ExerciseList.vue index 4457bb6..0c5fe17 100644 --- a/frontend/src/components/Exercise/ExerciseList.vue +++ b/frontend/src/components/Exercise/ExerciseList.vue @@ -1,7 +1,7 @@ @@ -22,7 +22,7 @@ v-model="searchQuery" variant="outlined" prepend-inner-icon="mdi-magnify" - label="Search exercises" + :label="$t('exercise.searchExercises')" clearable hide-details density="compact" @@ -37,7 +37,7 @@ height="40" variant="outlined" > - Filter + {{ $t('common.filter') }} mdi-close - Reset + {{ $t('common.reset') }} @@ -88,7 +88,7 @@
- {{ exercise.name }} + {{ displayName(exercise) }}
@@ -108,7 +108,7 @@ - Create new exercise + {{ $t('exercise.createNewExercise') }}
@@ -149,10 +149,13 @@ import type { Exercise } from '@/interfaces/Exercise.interface'; import type { MuscleGroup } from '@/interfaces/MuscleGroup.interface'; import { useExerciseStore } from '@/stores/exercise.store'; import { useMuscleGroupStore } from '@/stores/muscleGroup.store'; +import { useI18n } from 'vue-i18n'; +import { displayExerciseName } from '@/utils/exerciseDisplay'; const muscleGroupStore = useMuscleGroupStore(); const searchQuery = ref(''); const exerciseStore = useExerciseStore(); +const { t } = useI18n({ useScope: 'global' }); const isLoading = ref(false); const viewExercise = ref(null); const isViewExerciseOpen = ref(false); @@ -172,6 +175,8 @@ const muscleGroups = computed(() => { const selectedMuscleGroups = ref([]); +const displayName = (exercise: Exercise) => displayExerciseName({ t }, exercise); + const openViewExercise = (exercise: Exercise) => { viewExercise.value = exercise; isViewExerciseOpen.value = true; @@ -179,11 +184,11 @@ const openViewExercise = (exercise: Exercise) => { const exercises = computed(() => exerciseStore.exercises.filter((exercise: Exercise) => { + const name = displayName(exercise).toLowerCase(); + const desc = (exercise.description ?? '').toLowerCase(); const matchesSearch = - exercise.name.toLowerCase().includes(searchQuery.value.toLowerCase()) || - (exercise.description ?? '') - .toLowerCase() - .includes(searchQuery.value.toLowerCase()); + name.includes(searchQuery.value.toLowerCase()) || + desc.includes(searchQuery.value.toLowerCase()); const matchesMuscleGroup = selectedMuscleGroups.value.length === 0 || diff --git a/frontend/src/components/HomeHeader.vue b/frontend/src/components/HomeHeader.vue index 1571345..ee90134 100644 --- a/frontend/src/components/HomeHeader.vue +++ b/frontend/src/components/HomeHeader.vue @@ -3,9 +3,8 @@

- {{ user ? user.firstName + " " + user.lastName : "Guest" }} + {{ user ? user.firstName + " " + user.lastName : $t('home.guest') }}

- Lets Get Ready 💪 + {{ $t('home.ready') }}

@@ -40,7 +39,7 @@ - Logout + {{ $t('settings.logout') }} diff --git a/frontend/src/components/MyWorkouts.vue b/frontend/src/components/MyWorkouts.vue index 8beb037..3d41561 100644 --- a/frontend/src/components/MyWorkouts.vue +++ b/frontend/src/components/MyWorkouts.vue @@ -7,13 +7,13 @@
- Workouts + {{ $t('myWorkouts.workoutsHeading') }}

- My Workouts + {{ $t('myWorkouts.title') }}

- {{ workouts.length }} total + {{ workouts.length }} {{ $t('common.total') }}
@@ -25,17 +25,7 @@ prepend-icon="mdi-play" @click="startEmptySession" > - Start empty - - - Refresh + {{ $t('myWorkouts.startEmpty') }}
@@ -99,9 +89,9 @@
- {{ workout.exercises.length }} exercises + {{ workout.exercises.length }} {{ $t('myWorkouts.exercisesUnit') }} - {{ workout.time }} min + {{ workout.time }} {{ $t('units.minShort') }}
@@ -135,7 +125,7 @@
- Created + {{ $t('myWorkouts.created') }} {{ new Date(workout.createdAt as any).toLocaleDateString(undefined, { month: 'short', @@ -149,17 +139,9 @@ variant="text" color="primary" prepend-icon="mdi-play" + @click="startSession(workout.id)" > - Start - - - Duplicate + {{ $t('common.start') }}
@@ -184,17 +166,17 @@ mdi-dumbbell
- No workouts yet + {{ $t('myWorkouts.emptyTitle') }}
- Create your first workout and start training. + {{ $t('myWorkouts.emptyDescription') }}
- Create Workout + {{ $t('myWorkouts.createWorkout') }}
@@ -204,7 +186,7 @@ size="large" @click="isWorkoutListOpen = true" > - Show all workouts + {{ $t('myWorkouts.showAllWorkouts') }} - -
-

- Your Progress -

- - Days - mdi-chevron-down - - - - {{ item.title }} - - - - -
-
-
- - {{ day }} - -
-
-
-
-

- 4 -

-

- Workouts + +

+
+

{{ $t('progress.week') }} {{ currentWeek }}

+

+ {{ + $t('progress.weekProgress', { + current: streakInfo.currentWeekWorkouts, + goal: streakInfo.weeklyWorkoutGoal, + }) + }}

-
-

- 2000 -

-

- KCAL +

+ mdi-fire +

+ {{ streakInfo?.currentStreak || 0 }}

-
-

- 120 -

-

- Minutes -

+
+
+
+ + {{ day }} +
diff --git a/frontend/src/components/Session/EditSetDialog.vue b/frontend/src/components/Session/EditSetDialog.vue index 1a5763c..edf4660 100644 --- a/frontend/src/components/Session/EditSetDialog.vue +++ b/frontend/src/components/Session/EditSetDialog.vue @@ -11,40 +11,40 @@ class="d-flex flex-column" > - Edit Set {{ editableSet.set }} + {{ $t('session.editSetTitle', { set: editableSet.set }) }} - Save + {{ $t('common.save') }}

- WEIGHT + {{ $t('session.weightLabel') }}

- REPETITIONS + {{ $t('session.repetitionsLabel') }}

@@ -59,7 +59,7 @@ variant="outlined" @click="onDelete" > - Delete Set + {{ $t('session.deleteSet') }}
diff --git a/frontend/src/components/Session/TimerDialog.vue b/frontend/src/components/Session/TimerDialog.vue index 13b6852..17617e9 100644 --- a/frontend/src/components/Session/TimerDialog.vue +++ b/frontend/src/components/Session/TimerDialog.vue @@ -39,7 +39,7 @@ class="font-weight-bold flex-grow-1" @click="toggleTimer" > - {{ isRunning ? 'Pause' : 'Start' }} + {{ isRunning ? $t('timer.pause') : $t('timer.start') }} - Reset + {{ $t('common.reset') }}
- Close Timer + {{ $t('timer.closeTimer') }} diff --git a/frontend/src/components/Session/WorkoutExerciseCard.vue b/frontend/src/components/Session/WorkoutExerciseCard.vue index bb365c5..73f3bc8 100644 --- a/frontend/src/components/Session/WorkoutExerciseCard.vue +++ b/frontend/src/components/Session/WorkoutExerciseCard.vue @@ -2,7 +2,7 @@

- {{ resolvedExercise?.name || 'Loading...' }} + {{ resolvedExercise ? displayName(resolvedExercise) : $t('common.loading') }}

- Done + {{ $t('table.done') }} - Exercise details + {{ $t('session.exerciseDetails') }} - Add Set + {{ $t('session.addSet') }} - Delete Exercise + {{ $t('session.deleteExercise') }} @@ -62,7 +62,7 @@ mdi-lightbulb-outline

- {{ resolvedExercise?.description || 'No description available.' }} + {{ resolvedExercise ? displayDescription(resolvedExercise) : '' }}

@@ -89,7 +89,7 @@ v-if="props.showRpe" class="pa-4 pt-2 d-flex flex-column ga-3 bg-grey-darken-4" > - RPE (Rate of Perceived Exertion) + {{ $t('session.rpeLabel') }} - Update subsequent sets? + {{ $t('session.updateSubsequentSets') }} - Do you want to update sets {{ propagateSetsIndices }} with {{ propagateWeight }}kg and {{ propagateReps }} reps? + {{ $t('session.updateSetsPrompt', { sets: propagateSetsIndices, weight: propagateWeight, reps: propagateReps }) }} @@ -149,14 +149,14 @@ variant="text" @click="confirmPropagate(false)" > - No, just this one + {{ $t('session.noJustThisOne') }} - Yes, update all + {{ $t('session.yesUpdateAll') }} @@ -173,11 +173,16 @@ // TODO: Add functionality to view exercise details // TODO: Maybe have a info icon that shows exercise details in a dialog instead of a dropdown +import { useI18n } from 'vue-i18n'; +import { displayExerciseDescription, displayExerciseName } from '@/utils/exerciseDisplay'; + import type { Exercise as ExerciseProp, WorkoutSet, } from '@/interfaces/Workout.interface'; +const { t } = useI18n({ useScope: 'global' }); + const props = defineProps({ exercise: { type: Object as PropType, @@ -211,6 +216,12 @@ const emit = defineEmits<{ }>(); const resolvedExercise = computed(() => props.exercise.exercise); + +const displayName = (exercise: NonNullable) => + displayExerciseName({ t }, exercise); + +const displayDescription = (exercise: NonNullable) => + displayExerciseDescription({ t }, exercise, t('exerciseCatalog.noDescription')); const showDetails = ref(true); const isEditDialogVisible = ref(false); @@ -232,16 +243,16 @@ const propagateSetsIndices = computed(() => { if (indices.length === 0) return ''; if (indices.length === 1) return indices[0].toString(); const last = indices.pop(); - return indices.join(', ') + ' and ' + last; + return indices.join(', ') + ` ${t('common.and')} ` + last; }); -const headers = [ - { title: 'Set', key: 'set', sortable: false, width: '20%' }, - { title: 'Previous', key: 'previous', sortable: false, width: '30%' }, - { title: 'Weight (kg)', key: 'weight', sortable: false, width: '25%' }, - { title: 'Reps', key: 'reps', sortable: false, width: '15%' }, - { title: 'Done', key: 'done', sortable: false, width: '10%' }, -]; +const headers = computed(() => [ + { title: t('table.set'), key: 'set', sortable: false, width: '20%' }, + { title: t('table.previous'), key: 'previous', sortable: false, width: '30%' }, + { title: t('table.weightKg'), key: 'weight', sortable: false, width: '25%' }, + { title: t('table.reps'), key: 'reps', sortable: false, width: '15%' }, + { title: t('table.done'), key: 'done', sortable: false, width: '10%' }, +]); const allSetsDone = computed(() => { if (!props.workoutSets || props.workoutSets.length === 0) return false; diff --git a/frontend/src/components/Settings/SessionList.vue b/frontend/src/components/Settings/SessionList.vue index 4f16105..db3f2dd 100644 --- a/frontend/src/components/Settings/SessionList.vue +++ b/frontend/src/components/Settings/SessionList.vue @@ -1,10 +1,7 @@ \ No newline at end of file diff --git a/frontend/src/components/Workout/CreateWorkout.vue b/frontend/src/components/Workout/CreateWorkout.vue index 6aeadce..aae8e0c 100644 --- a/frontend/src/components/Workout/CreateWorkout.vue +++ b/frontend/src/components/Workout/CreateWorkout.vue @@ -1,7 +1,7 @@ - Default Reps: + {{ $t('exerciseForm.defaultReps') }}: {{ selectedExercise.reps @@ -86,12 +86,12 @@ - Default Pause: + {{ $t('exerciseForm.defaultPause') }}: {{ selectedExercise.pauseSeconds }} - seconds + {{ $t('units.sec') }} @@ -103,7 +103,7 @@ - Created at: + {{ $t('common.createdAt') }}: {{ new Date( @@ -120,7 +120,7 @@ - Updated at: + {{ $t('common.updatedAt') }}: {{ new Date( @@ -139,7 +139,7 @@ :loading="isLoading" @click="updateExercise" > - Save Changes + {{ $t('common.saveChanges') }}
@@ -190,6 +194,8 @@ import { } from '@/services/workout.service'; import { useWorkoutStore } from '@/stores/workout.store'; import { toast } from 'vuetify-sonner'; +import { useI18n } from 'vue-i18n'; +import { displayExerciseName, displayExerciseDescription } from '@/utils/exerciseDisplay'; const props = defineProps<{ workoutId?: number; @@ -200,6 +206,27 @@ const props = defineProps<{ const isViewExercise = ref(props.isViewExercise); +const { t } = useI18n({ useScope: 'global' }); + +const displayName = computed(() => + displayExerciseName({ t }, { + name: props.selectedExercise.exercise.name, + i18nKey: props.selectedExercise.exercise.i18nKey, + isNameCustom: props.selectedExercise.exercise.isNameCustom, + }), +); + +const displayDescription = computed(() => + displayExerciseDescription( + { t }, + { + description: props.selectedExercise.exercise.description, + i18nKey: props.selectedExercise.exercise.i18nKey, + }, + t('common.noDescription'), + ), +); + const exerciseStore = useExerciseStore(); const workoutStore = useWorkoutStore(); const isLoading = ref(false); @@ -236,7 +263,7 @@ const removeExercise = async () => { ); if (response) { - toast.success('Exercise removed successfully!', { progressBar: true }); + toast.success(t('exercise.removed'), { progressBar: true, duration: 1000 }); if (props.isViewWorkoutExercise) { await workoutStore.setWorkouts(true); } else { @@ -247,6 +274,7 @@ const removeExercise = async () => { console.error('Failed to remove exercise.'); } } catch (error) { + toast.error(t('exercise.removeError'), { progressBar: true, duration: 1000 }); console.error('Error in removeExerciseFromWorkout:', error); } }; @@ -283,11 +311,11 @@ const updateExercise = async () => { try { isLoading.value = true; if (!editExercise.value) { - toast.error('No exercise data to update.'); + toast.error(t('exercise.updateNoData'), { progressBar: true, duration: 1000 }); return; } if (!props.workoutId) { - toast.error('No workout ID provided.'); + toast.error(t('exercise.updateNoWorkoutId'), { progressBar: true, duration: 1000 }); return; } @@ -296,7 +324,7 @@ const updateExercise = async () => { ); if (!workoutExercise) { - toast.error('Could not find the exercise in the workout.'); + toast.error(t('exercise.updateNotFoundInWorkout'), { progressBar: true, duration: 1000 }); return; } @@ -306,14 +334,14 @@ const updateExercise = async () => { getSanitizedExerciseDataForWorkout() || {}, ); if (response) { - toast.success('Exercise updated successfully!', { progressBar: true }); + toast.success(t('exercise.updated'), { progressBar: true, duration: 1000 }); await workoutStore.setWorkouts(true); emit('close'); } else { - toast.error('Failed to update exercise.'); + toast.error(t('exercise.failedToUpdate'), { progressBar: true, duration: 1000 }); } } catch (error) { - toast.error('Error in updateExercise.'); + toast.error(t('exercise.updateError'), { progressBar: true, duration: 1000 }); console.error('Error in updateExercise:', error); } finally { isLoading.value = false; diff --git a/frontend/src/components/Workout/WeightAndRepsSettings.vue b/frontend/src/components/Workout/WeightAndRepsSettings.vue index 5e80336..1ea16f8 100644 --- a/frontend/src/components/Workout/WeightAndRepsSettings.vue +++ b/frontend/src/components/Workout/WeightAndRepsSettings.vue @@ -1,7 +1,7 @@ @@ -332,5 +679,7 @@ onMounted(() => { position: absolute; bottom: 12px; right: -4px; + height: 32px !important; + width: 32px !important; } \ No newline at end of file diff --git a/frontend/src/pages/WorkoutDetails.vue b/frontend/src/pages/WorkoutDetails.vue index f0bcd40..a8c5eb6 100644 --- a/frontend/src/pages/WorkoutDetails.vue +++ b/frontend/src/pages/WorkoutDetails.vue @@ -2,32 +2,30 @@
- -

@@ -59,10 +57,10 @@ variant="tonal" color="green-lighten-1" size="small" - :aria-label="`Show ${hiddenCount} more muscle groups`" + :aria-label="$t('workout.showMoreMuscleGroupsAria', { count: hiddenCount })" @click="isAllGroupsOpen = true" > - +{{ hiddenCount }} more + {{ $t('workout.moreCount', { count: hiddenCount }) }}

@@ -75,7 +73,7 @@ color="primary" @click="startSession" > - Start Session + {{ $t('workout.startSession') }}

- No exercises added yet. + {{ $t('workout.noExercisesYet') }}

- Add Exercise + {{ $t('session.addExercise') }}
@@ -109,14 +107,14 @@ >

- {{ exercise.exercise?.name }} + {{ exercise.exercise ? displayName(exercise.exercise) : '' }}

- {{ exercise.sets }} x {{ exercise.reps }} Reps + {{ $t('workout.setsTimesReps', { sets: exercise.sets, reps: exercise.reps }) }}

- {{ exercise.pauseSeconds }} sec pauses + {{ $t('workout.pauseSeconds', { seconds: exercise.pauseSeconds }) }}

{{ exercise.weight }}kg @@ -198,8 +196,8 @@ @@ -222,6 +220,10 @@ import { } from "@/services/workout.service"; import { toast } from "vuetify-sonner"; import EditWorkoutExercise from "@/components/Workout/EditWorkoutExercise.vue"; +import { useI18n } from 'vue-i18n'; +import { displayExerciseName } from '@/utils/exerciseDisplay'; + +const { t } = useI18n({ useScope: 'global' }); const isAddExerciseOpen = ref(false); const isEditExerciseOpen = ref(false); @@ -236,6 +238,8 @@ const workoutSessionStore = useWorkoutSessionStore(); const workout = computed(() => workoutStore.currentWorkout); const selectedExercise = ref(null); +const displayName = (exercise: NonNullable) => displayExerciseName({ t }, exercise); + type GroupStat = { name: string; count: number }; const groupStats = computed(() => { @@ -250,7 +254,7 @@ const groupStats = computed(() => { typeof mg === "object" && mg !== null ? mg.id : mg ) ?? []; return ids - .map((id) => muscleGroups.find((g) => g.id === id)?.name || "Unknown") + .map((id) => muscleGroups.find((g) => g.id === id)?.name || t('common.unknown')) .filter(Boolean); }); @@ -310,12 +314,12 @@ const updateWorkoutExercises = async (newExerciseIds: number[]) => { exercisesToAdd.length > 0 || exercisesToRemove.length > 0; if (hasBeenUpdated) { - toast.success("Workout updated successfully"); + toast.success(t('workout.updatedNoBang'), { progressBar: true, duration: 1000 }); await workoutStore.setWorkouts(true); } } catch (error) { console.error("Error updating workout exercises:", error); - toast.error("Failed to update workout"); + toast.error(t('workout.failedToUpdate'), { progressBar: true, duration: 1000 }); } finally { isUpdatingWorkout.value = false; } @@ -327,7 +331,7 @@ const dublicate = async () => { if (response && response.id) { await workoutStore.setWorkouts(true); workoutStore.setCurrentWorkout(response.id); - toast.success("Workout duplicated successfully", { progressBar: true }); + toast.success(t('workout.duplicated'), { progressBar: true, duration: 1000 }); router.push(`/workout/${response.id}`); } else { console.error("Failed to duplicate workout"); @@ -343,7 +347,7 @@ const deleteExercise = async () => { workoutStore.setWorkouts(true); workoutStore.currentWorkout = null; isDeleteDialogOpen.value = false; - toast.success("Exercise deleted successfully", { progressBar: true }); + toast.success(t('workout.deleted'), { progressBar: true, duration: 1000 }); router.push("/"); } else { console.error("Failed to delete exercise"); @@ -351,7 +355,7 @@ const deleteExercise = async () => { } } catch (error) { console.error("Error deleting exercise:", error); - toast.error("Failed to delete exercise", { progressBar: true }); + toast.error(t('workout.failedToDelete'), { progressBar: true, duration: 1000 }); isDeleteDialogOpen.value = false; } }; @@ -369,6 +373,7 @@ const startSession = async () => { router.push(`/session/${response.id}`); } else { console.error("Failed to start session:", response); + toast.error(t('workout.failedToStartSession'), { progressBar: true, duration: 1000 }); } } }; diff --git a/frontend/src/pages/index.vue b/frontend/src/pages/index.vue index 8d1dabd..cf2b34e 100644 --- a/frontend/src/pages/index.vue +++ b/frontend/src/pages/index.vue @@ -1,7 +1,7 @@ diff --git a/frontend/src/plugins/i18n.ts b/frontend/src/plugins/i18n.ts new file mode 100644 index 0000000..61725ab --- /dev/null +++ b/frontend/src/plugins/i18n.ts @@ -0,0 +1,42 @@ +import { createI18n } from 'vue-i18n'; + +import en from '@/locales/en'; +import sv from '@/locales/sv'; + +export type AppLocale = 'en' | 'sv'; + +const STORAGE_KEY = 'app'; + +function readPersistedLocale(): AppLocale | null { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return null; + + const parsed = JSON.parse(raw) as { locale?: unknown }; + if (parsed?.locale === 'en' || parsed?.locale === 'sv') return parsed.locale; + return null; + } catch { + return null; + } +} + +function defaultLocale(): AppLocale { + const persisted = typeof window !== 'undefined' ? readPersistedLocale() : null; + if (persisted) return persisted; + + const browser = typeof navigator !== 'undefined' ? navigator.language : 'en'; + return browser.toLowerCase().startsWith('sv') ? 'sv' : 'en'; +} + +const i18n = createI18n({ + legacy: false, + globalInjection: true, + locale: defaultLocale(), + fallbackLocale: 'en', + messages: { + en, + sv, + }, +}); + +export default i18n; diff --git a/frontend/src/plugins/index.ts b/frontend/src/plugins/index.ts index 06b1932..197b7ed 100644 --- a/frontend/src/plugins/index.ts +++ b/frontend/src/plugins/index.ts @@ -9,11 +9,12 @@ import vuetify from './vuetify'; import pinia from '../stores'; import router from '../router'; import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'; +import i18n from './i18n'; // Types import type { App } from 'vue'; export function registerPlugins (app: App) { - app.use(vuetify).use(router).use(pinia); + app.use(vuetify).use(router).use(pinia).use(i18n); pinia.use(piniaPluginPersistedstate); } diff --git a/frontend/src/plugins/vuetify.ts b/frontend/src/plugins/vuetify.ts index 7652788..c3d545b 100644 --- a/frontend/src/plugins/vuetify.ts +++ b/frontend/src/plugins/vuetify.ts @@ -12,8 +12,101 @@ import 'vuetify/styles' import { createVuetify } from 'vuetify' // https://vuetifyjs.com/en/introduction/why-vuetify/#feature-guides -export default createVuetify({ +export default createVuetify( { theme: { defaultTheme: 'dark', + themes: { + dark: { + dark: true, + colors: { + }, + }, + light: { + dark: false, + colors: { + }, + } + }, }, -}) + defaults: { + VBtn: { + density: 'compact', + height: '40px' + }, + VBtnToggle: { + density: 'compact' + }, + VAlert: { + density: 'compact' + }, + VBanner: { + density: 'compact' + }, + VTextField: { + density: 'compact' + }, + VTextarea: { + density: 'compact' + }, + VSelect: { + density: 'compact' + }, + VAutocomplete: { + density: 'compact' + }, + VCombobox: { + density: 'compact', + delimiters: [','] + }, + VFileInput: { + density: 'compact' + }, + VCheckbox: { + density: 'compact' + }, + VRadio: { + density: 'compact' + }, + VRadioGroup: { + density: 'compact' + }, + VSwitch: { + density: 'compact' + }, + VSlider: { + density: 'compact' + }, + VRangeSlider: { + density: 'compact' + }, + VToolbar: { + density: 'compact' + }, + VTabs: { + density: 'compact' + }, + VBreadcrumbs: { + density: 'compact' + }, + VPagination: { + density: 'compact' + }, + VDataTable: { + density: 'compact' + }, + VDataTableServer: { + density: 'compact' + }, + VTimeline: { + density: 'compact' + }, + VTimelineItem: { + }, + VAvatar: { + density: 'compact' + }, + VRating: { + density: 'compact' + } + }, +} ) \ No newline at end of file diff --git a/frontend/src/services/user.service.ts b/frontend/src/services/user.service.ts index b95d37e..a0498a6 100644 --- a/frontend/src/services/user.service.ts +++ b/frontend/src/services/user.service.ts @@ -1,4 +1,4 @@ -import type { CreateUser, User } from '@/interfaces/User.interface'; +import type { CreateUser, User, StreakInfo } from '@/interfaces/User.interface'; import { fetchWrapper } from '@/utils/fetchWrapper'; const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:8393/v1'; @@ -68,3 +68,26 @@ export const getCurrentUser = async () => { throw new Error('Failed to fetch current user'); } }; +export const getStreakInfo = async () => { + try { + const data = await fetchWrapper(`${apiUrl}/users/streak`); + return data; + } catch (error) { + console.error('Error fetching streak info:', error); + throw new Error('Failed to fetch streak info'); + } +}; + +export const updateWeeklyWorkoutGoal = async (weeklyWorkoutGoal: number) => { + try { + const data = await fetchWrapper(`${apiUrl}/users/weekly-goal`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ weeklyWorkoutGoal }), + }); + return data; + } catch (error) { + console.error('Error updating weekly workout goal:', error); + throw new Error('Failed to update weekly workout goal'); + } +}; \ No newline at end of file diff --git a/frontend/src/stores/app.ts b/frontend/src/stores/app.ts index 7429543..1a5f928 100644 --- a/frontend/src/stores/app.ts +++ b/frontend/src/stores/app.ts @@ -3,6 +3,14 @@ import { defineStore } from 'pinia' export const useAppStore = defineStore('app', { state: () => ({ - // + locale: 'en' as 'en' | 'sv', }), + actions: { + setLocale (locale: 'en' | 'sv') { + this.locale = locale + }, + }, + persist: { + pick: ['locale'], + }, }) diff --git a/frontend/src/stores/auth.store.ts b/frontend/src/stores/auth.store.ts index 0170262..837deb7 100644 --- a/frontend/src/stores/auth.store.ts +++ b/frontend/src/stores/auth.store.ts @@ -8,8 +8,9 @@ import { useMuscleGroupStore } from './muscleGroup.store'; import { useWorkoutSessionStore } from './workoutSession.store'; import { fetchWrapper } from '@/utils/fetchWrapper'; import type { User } from '@/interfaces/User.interface'; +import i18n from '@/plugins/i18n'; -const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:1337/v1'; +const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:8393/v1'; export const useAuthStore = defineStore( 'authStore', @@ -47,7 +48,7 @@ export const useAuthStore = defineStore( return data; } catch (error) { console.error('Login failed:', error); - toast.error('Login failed. Please check your credentials.'); + toast.error(i18n.global.t('auth.loginFailed'), { progressBar: true, duration: 1000 }); isAuthenticated.value = false; throw error; } finally { @@ -64,13 +65,13 @@ export const useAuthStore = defineStore( fullName: string; email: string; password: string; - }) => { + }): Promise => { loading.value = true; try { const firstName = registerData.fullName.split(' ')[0]; const lastName = registerData.fullName.split(' ')[1] || ''; - const response = await fetchWrapper(`${apiUrl}/auth/register`, { + await fetchWrapper(`${apiUrl}/auth/register`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -83,16 +84,23 @@ export const useAuthStore = defineStore( }), }); - if (response instanceof Response && response.ok) { - router.push('/login'); - } + // Auto-login after successful registration + await login(registerData.email, registerData.password); + return true; } catch (error) { console.error('Account creation failed:', error); - throw new Error('Account creation failed'); + const errorMessage = error instanceof Error ? error.message : ''; + + if (errorMessage.includes('User already exists')) { + toast.error(i18n.global.t('auth.accountAlreadyExists'), { progressBar: true, duration: 1000 }); + return false; + } + + toast.error(i18n.global.t('auth.accountCreationFailed'), { progressBar: true, duration: 1000 }); + return false; } finally { loading.value = false; } - return null; }; const resetStore = () => { diff --git a/frontend/src/stores/workoutSession.store.ts b/frontend/src/stores/workoutSession.store.ts index 7112e95..f760194 100644 --- a/frontend/src/stores/workoutSession.store.ts +++ b/frontend/src/stores/workoutSession.store.ts @@ -284,12 +284,12 @@ export const useWorkoutSessionStore = defineStore( isLoading.value = true; await workoutSessionService.deleteWorkoutSession(sessionId); workoutSessions.value = workoutSessions.value.filter( - (s: any) => s.id !== sessionId, + (s: { id?: number }) => s.id !== sessionId, ); delete liveSessions.value[sessionId]; if ( selectedWorkoutSession.value && - (selectedWorkoutSession.value as any).id === sessionId + (selectedWorkoutSession.value as { id?: number }).id === sessionId ) { selectedWorkoutSession.value = null; } diff --git a/frontend/src/utils/exerciseDisplay.ts b/frontend/src/utils/exerciseDisplay.ts new file mode 100644 index 0000000..6fd83c7 --- /dev/null +++ b/frontend/src/utils/exerciseDisplay.ts @@ -0,0 +1,40 @@ +import type { Composer } from 'vue-i18n'; + +import type { Exercise } from '@/interfaces/Exercise.interface'; +import type { GlobalExercise } from '@/interfaces/GlobalExercise.interface'; + +type Translator = Pick; + +function translatedOrFallback(translator: Translator, key: string, fallback: string): string { + const translated = translator.t(key); + return translated === key ? fallback : translated; +} + +export function displayExerciseName(translator: Translator, exercise: Pick): string { + if (exercise.isNameCustom && exercise.name) return exercise.name; + if (exercise.i18nKey) return translatedOrFallback(translator, `${exercise.i18nKey}.name`, exercise.name); + return exercise.name; +} + +export function displayExerciseDescription( + translator: Translator, + exercise: Pick, + fallback: string, +): string { + const current = (exercise.description ?? '').trim(); + if (exercise.i18nKey) return translatedOrFallback(translator, `${exercise.i18nKey}.description`, current || fallback); + return current || fallback; +} + +export function displayGlobalExerciseName(translator: Translator, exercise: Pick): string { + return translatedOrFallback(translator, `${exercise.i18nKey}.name`, exercise.defaultName); +} + +export function displayGlobalExerciseDescription( + translator: Translator, + exercise: Pick, + fallback: string, +): string { + const current = (exercise.defaultDescription ?? '').trim(); + return translatedOrFallback(translator, `${exercise.i18nKey}.description`, current || fallback); +}