Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion .github/workflows/sentry-test-analytics.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,4 @@ jobs:

- name: Upload test results to Sentry
if: ${{ !cancelled() }}
uses: getsentry/prevent-action
uses: getsentry/prevent-action@v0
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
-- CreateEnum
CREATE TYPE "AchievementType" AS ENUM ('STREAK_3_DAYS', 'STREAK_5_DAYS', 'STREAK_7_DAYS', 'STREAK_14_DAYS', 'STREAK_30_DAYS', 'FIRST_WORKOUT', 'WORKOUT_MARATHON', 'CONSISTENCY_CHAMPION');

-- AlterTable
ALTER TABLE "Profile" ADD COLUMN "targetCalories" INTEGER DEFAULT 2000,
ADD COLUMN "targetCarbs" INTEGER DEFAULT 244,
ADD COLUMN "targetFat" INTEGER DEFAULT 68,
ADD COLUMN "targetProtein" INTEGER DEFAULT 98;

-- CreateTable
CREATE TABLE "Achievement" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"type" "AchievementType" NOT NULL,
"count" INTEGER NOT NULL DEFAULT 1,
"earnedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,

CONSTRAINT "Achievement_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE INDEX "Achievement_userId_earnedAt_idx" ON "Achievement"("userId", "earnedAt");

-- CreateIndex
CREATE UNIQUE INDEX "Achievement_userId_type_key" ON "Achievement"("userId", "type");

-- AddForeignKey
ALTER TABLE "Achievement" ADD CONSTRAINT "Achievement_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
26 changes: 26 additions & 0 deletions app/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ model User {
profile Profile?
activities Activity[]
meals Meal[]
achievements Achievement[]
}

model VerificationToken {
Expand Down Expand Up @@ -135,3 +136,28 @@ model Meal {

@@index([userId, date])
}

enum AchievementType {
STREAK_3_DAYS
STREAK_5_DAYS
STREAK_7_DAYS
STREAK_14_DAYS
STREAK_30_DAYS
FIRST_WORKOUT
WORKOUT_MARATHON
CONSISTENCY_CHAMPION
}

model Achievement {
id String @id @default(cuid())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
type AchievementType
count Int @default(1)
earnedAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

@@unique([userId, type])
@@index([userId, earnedAt])
}
180 changes: 180 additions & 0 deletions app/src/app/api/achievements/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth.config';
import { PrismaClient, AchievementType } from '@prisma/client';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Multiple PrismaClient instances are being created throughout the codebase. In production, this can cause connection pool exhaustion. Create a singleton instance in a shared module (e.g., src/lib/prisma.ts) and import it instead of creating new instances in each route handler.
Severity: HIGH

🤖 Prompt for AI Agent

Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: app/src/app/api/achievements/route.ts#L5

Potential issue: Multiple PrismaClient instances are being created throughout the
codebase. In production, this can cause connection pool exhaustion. Create a singleton
instance in a shared module (e.g., `src/lib/prisma.ts`) and import it instead of
creating new instances in each route handler.

Did we get this right? 👍 / 👎 to inform future reviews.
Reference ID: 7293328

const prisma = new PrismaClient();

// Achievement definitions with metadata
const ACHIEVEMENT_DEFINITIONS = {
[AchievementType.STREAK_3_DAYS]: {
name: 'Streak Starter',
description: 'Work out for 3 days in a row',
icon: '🔥',
color: '#ff6b35',
gradient: 'linear-gradient(135deg, #ff6b35, #f7931e)'
},
[AchievementType.STREAK_5_DAYS]: {
name: 'Consistency King',
description: 'Work out for 5 days in a row',
icon: '👑',
color: '#ffd700',
gradient: 'linear-gradient(135deg, #ffd700, #ffed4e)'
},
[AchievementType.STREAK_7_DAYS]: {
name: 'Week Warrior',
description: 'Work out for 7 days in a row',
icon: '⚔️',
color: '#8b5cf6',
gradient: 'linear-gradient(135deg, #8b5cf6, #a855f7)'
},
[AchievementType.STREAK_14_DAYS]: {
name: 'Fortnight Fighter',
description: 'Work out for 14 days in a row',
icon: '🛡️',
color: '#06b6d4',
gradient: 'linear-gradient(135deg, #06b6d4, #0891b2)'
},
[AchievementType.STREAK_30_DAYS]: {
name: 'Monthly Master',
description: 'Work out for 30 days in a row',
icon: '🏆',
color: '#10b981',
gradient: 'linear-gradient(135deg, #10b981, #059669)'
},
[AchievementType.FIRST_WORKOUT]: {
name: 'First Steps',
description: 'Complete your first workout',
icon: '🎯',
color: '#3b82f6',
gradient: 'linear-gradient(135deg, #3b82f6, #1d4ed8)'
},
[AchievementType.WORKOUT_MARATHON]: {
name: 'Marathon Master',
description: 'Complete 26 workouts',
icon: '🏃‍♂️',
color: '#ef4444',
gradient: 'linear-gradient(135deg, #ef4444, #dc2626)'
},
[AchievementType.CONSISTENCY_CHAMPION]: {
name: 'Consistency Champion',
description: 'Work out 4+ days in a week',
icon: '💪',
color: '#f59e0b',
gradient: 'linear-gradient(135deg, #f59e0b, #d97706)'
Comment on lines +8 to +64
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Achievement metadata (ACHIEVEMENT_DEFINITIONS) is hardcoded in the route handler. This creates maintainability issues - the same definitions are needed in the components and services. Extract this to a shared constants file (e.g., src/lib/achievementConstants.ts) that can be imported by all modules needing these definitions.
Severity: MEDIUM

🤖 Prompt for AI Agent

Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: app/src/app/api/achievements/route.ts#L8-L64

Potential issue: Achievement metadata (ACHIEVEMENT_DEFINITIONS) is hardcoded in the
route handler. This creates maintainability issues - the same definitions are needed in
the components and services. Extract this to a shared constants file (e.g.,
`src/lib/achievementConstants.ts`) that can be imported by all modules needing these
definitions.

Did we get this right? 👍 / 👎 to inform future reviews.
Reference ID: 7293328

}
};

export async function GET(request: NextRequest) {
try {
const session = await getServerSession(authOptions);

if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

// Fetch user's achievements
const achievements = await prisma.achievement.findMany({
where: {
userId: session.user.id
},
orderBy: {
earnedAt: 'desc'
}
});

// Transform achievements with metadata
const achievementsWithMetadata = achievements.map(achievement => ({
id: achievement.id,
type: achievement.type,
count: achievement.count,
earnedAt: achievement.earnedAt,
...ACHIEVEMENT_DEFINITIONS[achievement.type]
}));

return NextResponse.json({ achievements: achievementsWithMetadata });
} catch (error) {
console.error('Error fetching achievements:', error);
return NextResponse.json(
{ error: 'Failed to fetch achievements' },
{ status: 500 }
);
}
}
Comment on lines +97 to +103
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The error handling in the catch block doesn't distinguish between different error types (validation errors, database errors, auth errors). Consider adding more specific error handling and logging to help with debugging. Also, ensure sensitive information (like full error objects) is not exposed to clients.
Severity: MEDIUM

🤖 Prompt for AI Agent

Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: app/src/app/api/achievements/route.ts#L97-L103

Potential issue: The error handling in the catch block doesn't distinguish between
different error types (validation errors, database errors, auth errors). Consider adding
more specific error handling and logging to help with debugging. Also, ensure sensitive
information (like full error objects) is not exposed to clients.

Did we get this right? 👍 / 👎 to inform future reviews.
Reference ID: 7293328


export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions);

if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

const { type } = await request.json();

if (!type || !Object.values(AchievementType).includes(type)) {
return NextResponse.json(
{ error: 'Invalid achievement type' },
{ status: 400 }
);
}

// Check if achievement already exists
const existingAchievement = await prisma.achievement.findUnique({
where: {
userId_type: {
userId: session.user.id,
type: type as AchievementType
}
}
});

if (existingAchievement) {
// Increment count
const updatedAchievement = await prisma.achievement.update({
where: {
id: existingAchievement.id
},
data: {
count: existingAchievement.count + 1,
Comment on lines +133 to +139
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The POST endpoint updates the earnedAt timestamp every time an achievement is re-earned. This changes the 'earned date' to the current time rather than keeping the original earned date. Consider either: 1) Not updating earnedAt on re-awards, or 2) Adding a separate lastAwardedAt field to track when it was most recently awarded. The current behavior could be confusing to users.
Severity: MEDIUM

🤖 Prompt for AI Agent

Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: app/src/app/api/achievements/route.ts#L133-L139

Potential issue: The POST endpoint updates the `earnedAt` timestamp every time an
achievement is re-earned. This changes the 'earned date' to the current time rather than
keeping the original earned date. Consider either: 1) Not updating earnedAt on
re-awards, or 2) Adding a separate `lastAwardedAt` field to track when it was most
recently awarded. The current behavior could be confusing to users.

Did we get this right? 👍 / 👎 to inform future reviews.
Reference ID: 7293328

earnedAt: new Date()
}
});

return NextResponse.json({
achievement: {
id: updatedAchievement.id,
type: updatedAchievement.type,
count: updatedAchievement.count,
earnedAt: updatedAchievement.earnedAt,
...ACHIEVEMENT_DEFINITIONS[updatedAchievement.type]
}
});
} else {
// Create new achievement
const newAchievement = await prisma.achievement.create({
data: {
userId: session.user.id,
type: type as AchievementType,
count: 1
}
});

return NextResponse.json({
achievement: {
id: newAchievement.id,
type: newAchievement.type,
count: newAchievement.count,
earnedAt: newAchievement.earnedAt,
...ACHIEVEMENT_DEFINITIONS[newAchievement.type]
}
});
}
} catch (error) {
console.error('Error creating/updating achievement:', error);
return NextResponse.json(
{ error: 'Failed to create/update achievement' },
Comment on lines +170 to +176
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Similar error handling issue in POST method catch block. The generic 'Failed to create/update achievement' message makes it difficult to debug issues. Consider logging the specific error type and providing more context in error responses.
Severity: MEDIUM

🤖 Prompt for AI Agent

Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: app/src/app/api/achievements/route.ts#L170-L176

Potential issue: Similar error handling issue in POST method catch block. The generic
'Failed to create/update achievement' message makes it difficult to debug issues.
Consider logging the specific error type and providing more context in error responses.

Did we get this right? 👍 / 👎 to inform future reviews.
Reference ID: 7293328

{ status: 500 }
);
}
}
9 changes: 9 additions & 0 deletions app/src/app/api/activities/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth.config";
import { PrismaClient, ActivityType, EffortLevel } from "@prisma/client";
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Multiple PrismaClient instances throughout the codebase create connection pool issues. Use a shared singleton instance instead of creating new instances in each route.
Severity: HIGH

🤖 Prompt for AI Agent

Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: app/src/app/api/activities/route.ts#L4

Potential issue: Multiple PrismaClient instances throughout the codebase create
connection pool issues. Use a shared singleton instance instead of creating new
instances in each route.

Did we get this right? 👍 / 👎 to inform future reviews.
Reference ID: 7293328

import { AchievementService } from "@/lib/achievementService";

const prisma = new PrismaClient();

Expand Down Expand Up @@ -84,6 +85,14 @@ export async function POST(req: NextRequest) {
},
});

// Check for achievements after creating the activity
try {
await AchievementService.checkAllAchievements(user.id);
} catch (error) {
console.error('Error checking achievements:', error);
// Don't fail the activity creation if achievement checking fails
}

return NextResponse.json({ activity });
}

Expand Down
13 changes: 12 additions & 1 deletion app/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import Link from "next/link";
import Button from "@/components/Button";
import Logo from "@/components/Logo";
import StreakBanner from "@/components/StreakBanner";
import AchievementsSection, { Achievement } from "@/components/AchievementsSection";
import { Line, Bar } from "react-chartjs-2";
import {
Chart as ChartJS,
Expand Down Expand Up @@ -103,6 +104,7 @@ export default function HomePage() {
const [meals, setMeals] = useState<Meal[]>([]);
const [dashboardData, setDashboardData] = useState<DashboardData | null>(null);
const [streakData, setStreakData] = useState<StreakData | null>(null);
const [achievements, setAchievements] = useState<Achievement[]>([]);
const [loading, setLoading] = useState(false);
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
const [activeChart, setActiveChart] = useState<'activity' | 'burned' | 'consumed'>('activity');
Expand Down Expand Up @@ -168,11 +170,12 @@ export default function HomePage() {
async function fetchData() {
setLoading(true);
try {
const [activitiesRes, mealsRes, dashboardRes, streakRes] = await Promise.all([
const [activitiesRes, mealsRes, dashboardRes, streakRes, achievementsRes] = await Promise.all([
fetch("/api/activities", { credentials: 'include' }),
fetch("/api/meals", { credentials: 'include' }),
fetch("/api/dashboard", { credentials: 'include' }),
fetch("/api/streak", { credentials: 'include' }),
fetch("/api/achievements", { credentials: 'include' }),
]);

if (activitiesRes.ok) {
Expand Down Expand Up @@ -206,6 +209,11 @@ export default function HomePage() {
const data = await streakRes.json();
setStreakData(data.streak);
}

if (achievementsRes.ok) {
const data = await achievementsRes.json();
setAchievements(data.achievements);
}
} catch (error) {
console.error('Error fetching data:', error);
} finally {
Expand Down Expand Up @@ -490,6 +498,9 @@ export default function HomePage() {
/>
)}

{/* Achievements Section */}
<AchievementsSection achievements={achievements} />

{/* Summary Stats */}
<Card className="p-6">
<h2 className="text-xl font-bold mb-4 text-fitfest-text dark:text-fitfest-subtle">
Expand Down
56 changes: 56 additions & 0 deletions app/src/components/AchievementBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import React from 'react';

interface AchievementBadgeProps {
name: string;
description: string;
icon: string;
count: number;
color: string;
gradient?: string;
}

const AchievementBadge: React.FC<AchievementBadgeProps> = ({
name,
description,
icon,
count,
color,
gradient
}) => {
const badgeStyle = gradient
? { background: gradient }
: { backgroundColor: color };

return (
<div className="relative group">
{/* Badge */}
<div
className="w-16 h-16 rounded-full flex items-center justify-center text-white text-2xl font-bold shadow-lg hover:scale-105 transition-transform duration-200 cursor-pointer"
style={badgeStyle}
title={`${name}: ${description}`}
>
{icon}
</div>

{/* Count Badge */}
{count > 1 && (
<div className="absolute -bottom-1 -right-1 bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 text-xs font-semibold px-2 py-1 rounded-full min-w-[20px] text-center shadow-sm">
x{count}
</div>
)}

{/* Tooltip */}
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-3 py-2 bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 text-sm rounded-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none whitespace-nowrap z-10">
<div className="font-semibold">{name}</div>
<div className="text-xs opacity-90">{description}</div>
{count > 1 && (
<div className="text-xs opacity-75 mt-1">Earned {count} times</div>
)}
{/* Arrow */}
<div className="absolute top-full left-1/2 transform -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-gray-900 dark:border-t-gray-100"></div>
</div>
</div>
);
};

export default AchievementBadge;
Loading
Loading