Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "backend",
"version": "1.0.0",
"version": "1.1.0",
"description": "",
"author": "",
"private": true,
Expand Down
57 changes: 57 additions & 0 deletions backend/src/v1/migrations/1775000000000-AddStreakFreezesToUser.ts
Original file line number Diff line number Diff line change
@@ -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
* <https://www.gnu.org/licenses/>.
*/

import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';

export class AddStreakFreezesToUser1775000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
// 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<void> {
await queryRunner.dropColumn('user', 'completedGoalWeeksCount');
await queryRunner.dropColumn('user', 'streakFreezeUsedWeek');
await queryRunner.dropColumn('user', 'streakFreezes');
}
}
14 changes: 9 additions & 5 deletions backend/src/v1/statistics/statistics.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -405,6 +405,7 @@ export class StatisticsService {
exerciseId: number;
sets: { weight: number; reps: number; rpe?: number }[];
}[],
manager?: EntityManager,
): Promise<
{
exerciseId: number;
Expand All @@ -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;
Expand Down Expand Up @@ -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 },
Expand All @@ -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,
Expand All @@ -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,
Expand Down
26 changes: 26 additions & 0 deletions backend/src/v1/user/dto/UseStreakFreeze.dto.ts
Original file line number Diff line number Diff line change
@@ -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
* <https://www.gnu.org/licenses/>.
*/

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;
}
27 changes: 24 additions & 3 deletions backend/src/v1/user/user.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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);
}
Comment on lines +153 to +163

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot apply changes based on this feedback

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit 422854f. Created UseStreakFreezeDto with @IsDateString() and @ApiProperty in backend/src/v1/user/dto/UseStreakFreeze.dto.ts, and updated the controller to accept @Body() body: UseStreakFreezeDto so the global ValidationPipe now validates and rejects invalid/missing date values, and Swagger reflects the schema.


@Put('weekly-goal')
@ApiOperation({ summary: 'Update weekly workout goal' })
@ApiOkResponse({ type: UserWithoutPasswordDto })
Expand Down Expand Up @@ -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,
Expand All @@ -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));
}
Expand Down
9 changes: 9 additions & 0 deletions backend/src/v1/user/user.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Loading
Loading