-
Notifications
You must be signed in to change notification settings - Fork 0
Adds achievements to the UX #26
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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; |
| 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'; | ||
|
|
||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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., 🤖 Prompt for AI Agent Did we get this right? 👍 / 👎 to inform future reviews. |
||
| } | ||
| }; | ||
|
|
||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. 🤖 Prompt for AI Agent Did we get this right? 👍 / 👎 to inform future reviews. |
||
|
|
||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The POST endpoint updates the 🤖 Prompt for AI Agent Did we get this right? 👍 / 👎 to inform future reviews. |
||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. 🤖 Prompt for AI Agent Did we get this right? 👍 / 👎 to inform future reviews. |
||
| { status: 500 } | ||
| ); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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"; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. 🤖 Prompt for AI Agent Did we get this right? 👍 / 👎 to inform future reviews. |
||
| import { AchievementService } from "@/lib/achievementService"; | ||
|
|
||
| const prisma = new PrismaClient(); | ||
|
|
||
|
|
@@ -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 }); | ||
| } | ||
|
|
||
|
|
||
| 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; |
There was a problem hiding this comment.
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
Did we get this right? 👍 / 👎 to inform future reviews.
Reference ID:
7293328