diff --git a/backend/package-lock.json b/backend/package-lock.json index 2b28bae..db655bc 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "backend", - "version": "1.0.0", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "backend", - "version": "1.0.0", + "version": "1.1.0", "license": "AGPL-3.0", "dependencies": { "@nestjs/common": "^11.1.3", diff --git a/backend/package.json b/backend/package.json index be6738b..13e4f01 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "backend", - "version": "1.0.0", + "version": "1.1.0", "description": "", "author": "", "private": true, diff --git a/backend/src/v1/migrations/1775000000000-AddStreakFreezesToUser.ts b/backend/src/v1/migrations/1775000000000-AddStreakFreezesToUser.ts new file mode 100644 index 0000000..5e468a9 --- /dev/null +++ b/backend/src/v1/migrations/1775000000000-AddStreakFreezesToUser.ts @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2026 FalkenDev + * + * This file is part of Grindify. + * + * Grindify is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * You should have received a copy of the GNU Affero General Public + * License along with Grindify. If not, see + * . + */ + +import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; + +export class AddStreakFreezesToUser1775000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // Add streakFreezes column — users start with 1 freeze + await queryRunner.addColumn( + 'user', + new TableColumn({ + name: 'streakFreezes', + type: 'int', + default: 1, + }), + ); + + // Add streakFreezeUsedWeek column — ISO week key e.g. "2026-W18" + await queryRunner.addColumn( + 'user', + new TableColumn({ + name: 'streakFreezeUsedWeek', + type: 'varchar', + length: '10', + isNullable: true, + }), + ); + + // Add completedGoalWeeksCount — tracks how many goal-weeks have been completed + await queryRunner.addColumn( + 'user', + new TableColumn({ + name: 'completedGoalWeeksCount', + type: 'int', + default: 0, + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropColumn('user', 'completedGoalWeeksCount'); + await queryRunner.dropColumn('user', 'streakFreezeUsedWeek'); + await queryRunner.dropColumn('user', 'streakFreezes'); + } +} diff --git a/backend/src/v1/statistics/statistics.service.ts b/backend/src/v1/statistics/statistics.service.ts index d6ceb51..294df5c 100644 --- a/backend/src/v1/statistics/statistics.service.ts +++ b/backend/src/v1/statistics/statistics.service.ts @@ -15,7 +15,7 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { EntityManager, Repository } from 'typeorm'; import { ExerciseRecord, RecordType } from './exerciseRecord.entity'; import { WorkoutSession } from '../workoutSession/workoutSession.entity'; import { WorkoutSessionExercise } from '../workoutSession/workoutSessionExercise.entity'; @@ -405,6 +405,7 @@ export class StatisticsService { exerciseId: number; sets: { weight: number; reps: number; rpe?: number }[]; }[], + manager?: EntityManager, ): Promise< { exerciseId: number; @@ -420,6 +421,9 @@ export class StatisticsService { isNew: boolean; }[] = []; const now = new Date(); + const recordRepo = manager + ? manager.getRepository(ExerciseRecord) + : this.recordRepo; for (const ce of completedExercises) { const { exerciseId, sets } = ce; @@ -501,7 +505,7 @@ export class StatisticsService { for (const candidate of candidates) { if (candidate.value <= 0) continue; - const existing = await this.recordRepo.findOne({ + const existing = await recordRepo.findOne({ where: { user: { id: userId }, exercise: { id: exerciseId }, @@ -510,8 +514,8 @@ export class StatisticsService { }); if (!existing) { - await this.recordRepo.save( - this.recordRepo.create({ + await recordRepo.save( + recordRepo.create({ user: { id: userId } as any, exercise: { id: exerciseId } as any, workoutSession: { id: sessionId } as any, @@ -532,7 +536,7 @@ export class StatisticsService { existing.workoutSession = { id: sessionId } as any; existing.achievedAt = now; existing.setDetails = candidate.setDetails; - await this.recordRepo.save(existing); + await recordRepo.save(existing); newRecords.push({ exerciseId, recordType: candidate.recordType, diff --git a/backend/src/v1/user/dto/UseStreakFreeze.dto.ts b/backend/src/v1/user/dto/UseStreakFreeze.dto.ts new file mode 100644 index 0000000..6fb6120 --- /dev/null +++ b/backend/src/v1/user/dto/UseStreakFreeze.dto.ts @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026 FalkenDev + * + * This file is part of Grindify. + * + * Grindify is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * You should have received a copy of the GNU Affero General Public + * License along with Grindify. If not, see + * . + */ + +import { IsDateString } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class UseStreakFreezeDto { + @ApiProperty({ + description: 'ISO 8601 date string (YYYY-MM-DD) of a day in the current week to freeze', + example: '2026-05-08', + }) + @IsDateString() + date: string; +} diff --git a/backend/src/v1/user/user.controller.ts b/backend/src/v1/user/user.controller.ts index a0fc631..2c2cb89 100644 --- a/backend/src/v1/user/user.controller.ts +++ b/backend/src/v1/user/user.controller.ts @@ -44,6 +44,7 @@ import { import { UserWithoutPasswordDto } from '../auth/dto/UserWithoutPassword.dto'; import { UpdateUserDto } from './dto/UpdateUser.dto'; import { UpdateUserPreferencesDto } from './dto/UpdateUserPreferences.dto'; +import { UseStreakFreezeDto } from './dto/UseStreakFreeze.dto'; import { UploadService } from '../upload/upload.service'; @ApiTags('users') @@ -149,6 +150,18 @@ export class UserController { return this.userService.getStreakInfo(+req.user.id); } + @Post('streak/freeze') + @ApiOperation({ summary: 'Use a streak freeze to protect the current week' }) + @ApiOkResponse({ + description: 'Updated streak info after consuming the freeze', + }) + useStreakFreeze(@Req() req: RequestWithUser, @Body() body: UseStreakFreezeDto) { + if (!req.user?.id) { + throw new UnauthorizedException('User not authenticated'); + } + return this.userService.useStreakFreeze(+req.user.id, body.date); + } + @Put('weekly-goal') @ApiOperation({ summary: 'Update weekly workout goal' }) @ApiOkResponse({ type: UserWithoutPasswordDto }) @@ -179,8 +192,13 @@ export class UserController { } @Get('export') - @ApiOperation({ summary: 'Export all user data (GDPR Art. 20 data portability)' }) - @ApiOkResponse({ description: 'JSON file containing all personal data for the authenticated user' }) + @ApiOperation({ + summary: 'Export all user data (GDPR Art. 20 data portability)', + }) + @ApiOkResponse({ + description: + 'JSON file containing all personal data for the authenticated user', + }) async exportData( @Req() req: RequestWithUser, @Res() res: Response, @@ -189,7 +207,10 @@ export class UserController { throw new UnauthorizedException('User not authenticated'); } const data = await this.userService.exportUserData(+req.user.id); - res.setHeader('Content-Disposition', 'attachment; filename="grindify-data-export.json"'); + res.setHeader( + 'Content-Disposition', + 'attachment; filename="grindify-data-export.json"', + ); res.setHeader('Content-Type', 'application/json'); res.send(JSON.stringify(data, null, 2)); } diff --git a/backend/src/v1/user/user.entity.ts b/backend/src/v1/user/user.entity.ts index aa93dd9..5e0ce83 100644 --- a/backend/src/v1/user/user.entity.ts +++ b/backend/src/v1/user/user.entity.ts @@ -65,6 +65,15 @@ export class User { @Column({ default: 0 }) currentWeekWorkouts: number; + @Column({ default: 1 }) + streakFreezes: number; + + @Column({ type: 'varchar', length: 10, nullable: true }) + streakFreezeUsedWeek: string | null; + + @Column({ default: 0 }) + completedGoalWeeksCount: number; + // Onboarding & Preferences @Column({ type: 'varchar', length: 20, nullable: true }) unitScale: string; // 'metric' or 'imperial' diff --git a/backend/src/v1/user/user.service.ts b/backend/src/v1/user/user.service.ts index e6041ac..9203f0e 100644 --- a/backend/src/v1/user/user.service.ts +++ b/backend/src/v1/user/user.service.ts @@ -259,22 +259,62 @@ export class UserService { lastWeekSunday, ); + // Get the ISO week key of the previous week for freeze checking + const prevWeekKey = this.getISOWeekKey(lastWeekMonday); + // Check if user met their goal in the previous week if (workoutDays < user.weeklyWorkoutGoal) { - // Didn't meet goal, reset streak to 0 - user.currentStreak = 0; + // Didn't meet goal — check if a freeze protects this week + if (user.streakFreezeUsedWeek === prevWeekKey) { + // Freeze consumed — streak survives + user.streakFreezeUsedWeek = null; + } else { + 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; + // Clear any stale freeze + user.streakFreezeUsedWeek = null; + } else { + // Goal met for exactly last week — award a freeze if earned + user.completedGoalWeeksCount = (user.completedGoalWeeksCount || 0) + 1; + if ( + user.completedGoalWeeksCount % 2 === 0 && + (user.streakFreezes || 0) < 2 + ) { + user.streakFreezes = (user.streakFreezes || 0) + 1; + } } // 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; + + // Update lastStreakCheckDate to the start of the current week so that + // repeated calls (e.g. from getStreakInfo) don't re-run the rollover + // logic and incorrectly increment completedGoalWeeksCount again. + user.lastStreakCheckDate = currentWeekMonday; } } + /** + * Returns the ISO week key for a given date, e.g. "2026-W18" + */ + private getISOWeekKey(date: Date): string { + const d = new Date( + Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()), + ); + const dayNum = d.getUTCDay() || 7; + d.setUTCDate(d.getUTCDate() + 4 - dayNum); + const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); + const weekNo = Math.ceil( + ((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7, + ); + return `${d.getUTCFullYear()}-W${String(weekNo).padStart(2, '0')}`; + } + /** * Count total sessions with either workout sessions or activity logs in a date range */ @@ -341,6 +381,52 @@ export class UserService { await this.userRepo.save(user); } + /** + * Use a streak freeze to protect the current week from a streak reset + */ + async useStreakFreeze( + userId: number, + date: string, + ): Promise<{ + currentStreak: number; + weeklyWorkoutGoal: number; + currentWeekWorkouts: number; + progressPercentage: number; + streakFreezes: number; + freezeUsedThisWeek: boolean; + streakFreezeUsedWeek: string | null; + }> { + const user = await this.userRepo.findOne({ where: { id: userId } }); + if (!user) throw new NotFoundException('User not found'); + + if ((user.streakFreezes || 0) <= 0) { + throw new BadRequestException('No streak freezes available'); + } + + const now = new Date(); + const currentWeekKey = this.getISOWeekKey(now); + + const targetDate = new Date(date + 'T12:00:00'); + if (Number.isNaN(targetDate.getTime())) { + throw new BadRequestException('Invalid date format'); + } + const targetWeekKey = this.getISOWeekKey(targetDate); + + if (targetWeekKey !== currentWeekKey) { + throw new BadRequestException('Can only freeze the current week'); + } + + if (user.streakFreezeUsedWeek === currentWeekKey) { + throw new BadRequestException('A freeze is already active this week'); + } + + user.streakFreezeUsedWeek = currentWeekKey; + user.streakFreezes = (user.streakFreezes || 0) - 1; + await this.userRepo.save(user); + + return this.getStreakInfo(userId); + } + /** * Get user's current streak information */ @@ -349,6 +435,9 @@ export class UserService { weeklyWorkoutGoal: number; currentWeekWorkouts: number; progressPercentage: number; + streakFreezes: number; + freezeUsedThisWeek: boolean; + streakFreezeUsedWeek: string | null; }> { const user = await this.userRepo.findOne({ where: { id: userId } }); if (!user) { @@ -390,11 +479,16 @@ export class UserService { ) : 0; + const currentWeekKey = this.getISOWeekKey(now); + return { currentStreak: user.currentStreak, weeklyWorkoutGoal: user.weeklyWorkoutGoal, currentWeekWorkouts: user.currentWeekWorkouts, progressPercentage: Math.round(progressPercentage), + streakFreezes: user.streakFreezes ?? 1, + freezeUsedThisWeek: user.streakFreezeUsedWeek === currentWeekKey, + streakFreezeUsedWeek: user.streakFreezeUsedWeek ?? null, }; } @@ -431,16 +525,35 @@ export class UserService { throw new NotFoundException('User not found'); } - const [exercises, workouts, sessions, activityLogs, weightLogs, progressPhotos, exerciseRecords] = - await Promise.all([ - this.exerciseRepo.find({ where: { createdBy: { id: userId } }, relations: ['media'] }), - this.workoutRepo.find({ where: { createdBy: { id: userId } }, relations: ['exercises'] }), - this.sessionRepo.find({ where: { user: { id: userId } }, relations: ['exercises', 'exercises.sets'] }), - this.activityLogRepo.find({ where: { user: { id: userId } } }), - this.weightLogRepo.find({ where: { user: { id: userId } } }), - this.progressPhotoRepo.find({ where: { user: { id: userId } } }), - this.exerciseRecordRepo.find({ where: { user: { id: userId } }, relations: ['exercise'] }), - ]); + const [ + exercises, + workouts, + sessions, + activityLogs, + weightLogs, + progressPhotos, + exerciseRecords, + ] = await Promise.all([ + this.exerciseRepo.find({ + where: { createdBy: { id: userId } }, + relations: ['media'], + }), + this.workoutRepo.find({ + where: { createdBy: { id: userId } }, + relations: ['exercises'], + }), + this.sessionRepo.find({ + where: { user: { id: userId } }, + relations: ['exercises', 'exercises.sets'], + }), + this.activityLogRepo.find({ where: { user: { id: userId } } }), + this.weightLogRepo.find({ where: { user: { id: userId } } }), + this.progressPhotoRepo.find({ where: { user: { id: userId } } }), + this.exerciseRecordRepo.find({ + where: { user: { id: userId } }, + relations: ['exercise'], + }), + ]); return { exportedAt: new Date().toISOString(), diff --git a/backend/src/v1/workoutSession/workoutSession.service.ts b/backend/src/v1/workoutSession/workoutSession.service.ts index 6deea6e..5459403 100644 --- a/backend/src/v1/workoutSession/workoutSession.service.ts +++ b/backend/src/v1/workoutSession/workoutSession.service.ts @@ -346,6 +346,7 @@ export class WorkoutSessionService { userId, saved.id, completedForRecords, + manager, ); } } @@ -567,6 +568,7 @@ export class WorkoutSessionService { userId, session.id, completedForRecords, + manager, ); } @@ -760,6 +762,7 @@ export class WorkoutSessionService { userId, sessionId, exercisesForRecords, + manager, ); } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5d5d6cc..15ff7b5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "frontend", - "version": "1.0.0", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "frontend", - "version": "1.0.0", + "version": "1.1.0", "license": "AGPL-3.0", "dependencies": { "@fontsource/roboto": "5.2.5", @@ -1642,7 +1642,7 @@ "version": "2.11.0", "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.11.0.tgz", "integrity": "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==", - "dev": true, + "devOptional": true, "license": "(Apache-2.0 AND BSD-3-Clause)" }, "node_modules/@canvas/image-data": { @@ -1668,7 +1668,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1685,7 +1684,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1702,7 +1700,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1719,7 +1716,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1736,7 +1732,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1753,7 +1748,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1770,7 +1764,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1787,7 +1780,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1804,7 +1796,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1821,7 +1812,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1838,7 +1828,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1855,7 +1844,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1872,7 +1860,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1889,7 +1876,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1906,7 +1892,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1923,7 +1908,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1940,7 +1924,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1957,7 +1940,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1974,7 +1956,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1991,7 +1972,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2008,7 +1988,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2025,7 +2004,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2042,7 +2020,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2059,7 +2036,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2076,7 +2052,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2093,7 +2068,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2845,7 +2819,7 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -2867,7 +2841,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -2877,7 +2851,7 @@ "version": "0.3.11", "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -2894,7 +2868,7 @@ "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -3189,7 +3163,6 @@ "version": "2.5.6", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -3229,7 +3202,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3250,7 +3222,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3271,7 +3242,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3292,7 +3262,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3313,7 +3282,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3334,7 +3302,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3355,7 +3322,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3376,7 +3342,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3397,7 +3362,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3418,7 +3382,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3439,7 +3402,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3460,7 +3422,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3481,7 +3442,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3608,7 +3568,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3622,7 +3581,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3636,7 +3594,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3650,7 +3607,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3664,7 +3620,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3678,7 +3633,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3692,7 +3646,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3706,7 +3659,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3720,7 +3672,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3734,7 +3685,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3748,7 +3698,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3762,7 +3711,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3776,7 +3724,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3790,7 +3737,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3804,7 +3750,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3818,7 +3763,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3832,7 +3776,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3846,7 +3789,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3860,7 +3802,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3874,7 +3815,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3888,7 +3828,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3902,7 +3841,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3916,7 +3854,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3930,7 +3867,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3944,7 +3880,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4058,7 +3993,7 @@ "version": "22.19.15", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -5138,7 +5073,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/cac": { @@ -5271,7 +5206,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "readdirp": "^4.0.1" @@ -5334,14 +5269,14 @@ "version": "0.5.2", "resolved": "https://registry.npmjs.org/colorjs.io/-/colorjs.io-0.5.2.tgz", "integrity": "sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/common-tags": { @@ -5621,7 +5556,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -6499,7 +6434,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -6904,7 +6838,7 @@ "version": "5.1.5", "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz", "integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/import-fresh": { @@ -7997,7 +7931,6 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", - "dev": true, "license": "MIT", "optional": true }, @@ -9065,7 +8998,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">= 14.18.0" @@ -9387,7 +9320,7 @@ "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.1.0" @@ -9473,7 +9406,6 @@ "version": "1.98.0", "resolved": "https://registry.npmjs.org/sass/-/sass-1.98.0.tgz", "integrity": "sha512-+4N/u9dZ4PrgzGgPlKnaaRQx64RO0JBKs9sDhQ2pLgN6JQZ25uPQZKQYaBJU48Kd5BxgXoJ4e09Dq7nMcOUW3A==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -9495,7 +9427,7 @@ "version": "1.98.0", "resolved": "https://registry.npmjs.org/sass-embedded/-/sass-embedded-1.98.0.tgz", "integrity": "sha512-Do7u6iRb6K+lrllcTkB1BXcHwOxcKe3rEfOF/GcCLE2w3WpddakRAosJOHFUR37DpsvimQXEt5abs3NzUjEIqg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@bufbuild/protobuf": "^2.5.0", @@ -9543,7 +9475,6 @@ "!riscv64", "!x64" ], - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -9557,7 +9488,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9574,7 +9504,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9591,7 +9520,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9608,7 +9536,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9625,7 +9552,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9642,7 +9568,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9659,7 +9584,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9676,7 +9600,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9693,7 +9616,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9710,7 +9632,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9727,7 +9648,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9744,7 +9664,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9761,7 +9680,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9778,7 +9696,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9792,7 +9709,6 @@ "version": "1.98.0", "resolved": "https://registry.npmjs.org/sass-embedded-unknown-all/-/sass-embedded-unknown-all-1.98.0.tgz", "integrity": "sha512-C4MMzcAo3oEDQnW7L8SBgB9F2Fq5qHPnaYTZRMOH3Mp/7kM4OooBInXpCiiFjLnjY95hzP4KyctVx0uYR6MYlQ==", - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9812,7 +9728,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9829,7 +9744,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9843,7 +9757,7 @@ "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0" @@ -10173,7 +10087,7 @@ "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "buffer-from": "^1.0.0", @@ -10184,7 +10098,7 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -10425,7 +10339,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/sync-child-process/-/sync-child-process-1.0.2.tgz", "integrity": "sha512-8lD+t2KrrScJ/7KXCSyfhT3/hRq78rC0wBFqNJXv3mZyn6hW2ypM05JmlSvtqRbeq6jqA94oHbxAr2vYsJ8vDA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "sync-message-port": "^1.0.0" @@ -10438,7 +10352,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/sync-message-port/-/sync-message-port-1.2.0.tgz", "integrity": "sha512-gAQ9qrUN/UCypHtGFbbe7Rc/f9bzO88IwrG8TDo/aMKAApKyD6E3W4Cm0EfhfBb6Z6SKt59tTCTfD+n1xmAvMg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=16.0.0" @@ -10477,7 +10391,7 @@ "version": "5.46.1", "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.1.tgz", "integrity": "sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==", - "dev": true, + "devOptional": true, "license": "BSD-2-Clause", "dependencies": { "@jridgewell/source-map": "^0.3.3", @@ -10817,7 +10731,7 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/unicode-canonical-property-names-ecmascript": { @@ -11229,7 +11143,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/varint/-/varint-6.0.0.tgz", "integrity": "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/vite": { @@ -12254,7 +12168,7 @@ "version": "2.8.2", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", - "dev": true, + "devOptional": true, "license": "ISC", "bin": { "yaml": "bin.mjs" diff --git a/frontend/package.json b/frontend/package.json index 9c5c450..babd9ff 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -2,7 +2,7 @@ "name": "frontend", "private": true, "type": "module", - "version": "1.0.0", + "version": "1.1.0", "license": "AGPL-3.0", "engines": { "node": ">=20" diff --git a/frontend/src/components/Session/WorkoutExerciseCard.vue b/frontend/src/components/Session/WorkoutExerciseCard.vue index b734708..d72d46a 100644 --- a/frontend/src/components/Session/WorkoutExerciseCard.vue +++ b/frontend/src/components/Session/WorkoutExerciseCard.vue @@ -44,6 +44,12 @@ > mdi-timer-play-outline + + mdi-table-row-plus-after +
mdi-lightbulb-outline -

+

{{ resolvedExercise ? displayDescription(resolvedExercise) : '' }}

@@ -282,12 +288,6 @@ const allSetsDone = computed(() => { return props.workoutSets.every((set) => set.done); }); -watch(allSetsDone, (isCompleted) => { - if (isCompleted) { - showDetails.value = false; - } -}); - function handleRowClick(event: Event, { item }: { item: WorkoutSet }) { selectedSet.value = { ...item }; isEditDialogVisible.value = true; @@ -341,6 +341,7 @@ function deleteSet(setToDelete: WorkoutSet) { } function addSet() { + showDetails.value = true; emit('add:set'); } @@ -348,15 +349,9 @@ function onDoneChanged(set: WorkoutSet, isDone: boolean) { emit('update:set', { ...set, done: isDone }); } -watch( - allSetsDone, - (isCompleted) => { - if (isCompleted) { - showDetails.value = false; - } - }, - { immediate: true } -); +watch(allSetsDone, (isCompleted) => { + showDetails.value = !isCompleted; +}, { immediate: true }); diff --git a/frontend/src/pages/Session.vue b/frontend/src/pages/Session.vue index 96aeebc..ac3aa06 100644 --- a/frontend/src/pages/Session.vue +++ b/frontend/src/pages/Session.vue @@ -25,7 +25,7 @@ -
+

{{ clock }}

diff --git a/frontend/src/pages/WorkoutDetails.vue b/frontend/src/pages/WorkoutDetails.vue index 953c3c3..07a6443 100644 --- a/frontend/src/pages/WorkoutDetails.vue +++ b/frontend/src/pages/WorkoutDetails.vue @@ -228,7 +228,7 @@
-
+
{{ $t('workout.startSession') }} @@ -435,13 +435,4 @@ const duplicate = async () => { .cursor-pointer { cursor: pointer; } - -.sticky-btn-wrapper { - position: fixed; - left: 0; - right: 0; - z-index: 10; - background: linear-gradient(180deg, rgba(12, 14, 18, 0) 0%, rgba(12, 14, 18, 1) 40%); - padding-top: 24px !important; -} diff --git a/frontend/src/pages/index.vue b/frontend/src/pages/index.vue index 52be476..5bdbb33 100644 --- a/frontend/src/pages/index.vue +++ b/frontend/src/pages/index.vue @@ -125,9 +125,18 @@ flex: '1', }" > - mdi-bullseye -

- {{ streakInfo?.currentWeekWorkouts || 0 }}/{{ streakInfo?.weeklyWorkoutGoal || 3 }} + + {{ streakInfo?.freezeUsedThisWeek ? 'mdi-snowflake' : 'mdi-bullseye' }} + +

+ {{ + streakInfo?.freezeUsedThisWeek + ? '✓' + : `${streakInfo?.currentWeekWorkouts || 0}/${streakInfo?.weeklyWorkoutGoal || 3}` + }}

{{ $t('home.weeklyGoal') }}

diff --git a/frontend/src/services/user.service.ts b/frontend/src/services/user.service.ts index 702fef4..11b2522 100644 --- a/frontend/src/services/user.service.ts +++ b/frontend/src/services/user.service.ts @@ -93,6 +93,20 @@ export const getStreakInfo = async () => { } } +export const useStreakFreeze = async (date: string) => { + try { + const data = await fetchWrapper(`${apiUrl}/users/streak/freeze`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ date }), + }) + return data + } catch (error) { + console.error('Error using streak freeze:', error) + throw new Error('Failed to use streak freeze') + } +} + export const updateWeeklyWorkoutGoal = async (weeklyWorkoutGoal: number) => { try { const data = await fetchWrapper(`${apiUrl}/users/weekly-goal`, {