diff --git a/.github/workflows/sentry-test-analytics.yml b/.github/workflows/sentry-test-analytics.yml index d3a36f6..c7e2137 100644 --- a/.github/workflows/sentry-test-analytics.yml +++ b/.github/workflows/sentry-test-analytics.yml @@ -42,4 +42,4 @@ jobs: - name: Upload test results to Sentry if: ${{ !cancelled() }} - uses: getsentry/prevent-action + uses: getsentry/prevent-action@v0 diff --git a/app/prisma/migrations/20250926200701_add_achievements/migration.sql b/app/prisma/migrations/20250926200701_add_achievements/migration.sql new file mode 100644 index 0000000..3c44d9a --- /dev/null +++ b/app/prisma/migrations/20250926200701_add_achievements/migration.sql @@ -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; diff --git a/app/prisma/schema.prisma b/app/prisma/schema.prisma index e58aa75..bddf4b8 100644 --- a/app/prisma/schema.prisma +++ b/app/prisma/schema.prisma @@ -51,6 +51,7 @@ model User { profile Profile? activities Activity[] meals Meal[] + achievements Achievement[] } model VerificationToken { @@ -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]) +} diff --git a/app/src/app/api/achievements/route.ts b/app/src/app/api/achievements/route.ts new file mode 100644 index 0000000..617a43e --- /dev/null +++ b/app/src/app/api/achievements/route.ts @@ -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)' + } +}; + +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 } + ); + } +} + +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, + 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' }, + { status: 500 } + ); + } +} diff --git a/app/src/app/api/activities/route.ts b/app/src/app/api/activities/route.ts index 9614127..ca82554 100644 --- a/app/src/app/api/activities/route.ts +++ b/app/src/app/api/activities/route.ts @@ -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"; +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 }); } diff --git a/app/src/app/page.tsx b/app/src/app/page.tsx index 64ccd8b..55f29a8 100644 --- a/app/src/app/page.tsx +++ b/app/src/app/page.tsx @@ -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, @@ -103,6 +104,7 @@ export default function HomePage() { const [meals, setMeals] = useState([]); const [dashboardData, setDashboardData] = useState(null); const [streakData, setStreakData] = useState(null); + const [achievements, setAchievements] = useState([]); const [loading, setLoading] = useState(false); const [isAuthenticated, setIsAuthenticated] = useState(null); const [activeChart, setActiveChart] = useState<'activity' | 'burned' | 'consumed'>('activity'); @@ -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) { @@ -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 { @@ -490,6 +498,9 @@ export default function HomePage() { /> )} + {/* Achievements Section */} + + {/* Summary Stats */}

diff --git a/app/src/components/AchievementBadge.tsx b/app/src/components/AchievementBadge.tsx new file mode 100644 index 0000000..6b85b13 --- /dev/null +++ b/app/src/components/AchievementBadge.tsx @@ -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 = ({ + name, + description, + icon, + count, + color, + gradient +}) => { + const badgeStyle = gradient + ? { background: gradient } + : { backgroundColor: color }; + + return ( +
+ {/* Badge */} +
+ {icon} +
+ + {/* Count Badge */} + {count > 1 && ( +
+ x{count} +
+ )} + + {/* Tooltip */} +
+
{name}
+
{description}
+ {count > 1 && ( +
Earned {count} times
+ )} + {/* Arrow */} +
+
+
+ ); +}; + +export default AchievementBadge; diff --git a/app/src/components/AchievementsSection.tsx b/app/src/components/AchievementsSection.tsx new file mode 100644 index 0000000..e8d0f37 --- /dev/null +++ b/app/src/components/AchievementsSection.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import AchievementBadge from './AchievementBadge'; + +export interface Achievement { + id: string; + name: string; + description: string; + icon: string; + count: number; + color: string; + gradient?: string; + earnedAt?: Date; +} + +interface AchievementsSectionProps { + achievements: Achievement[]; +} + +const AchievementsSection: React.FC = ({ achievements }) => { + if (achievements.length === 0) { + return ( +
+

Achievements

+
+
🏆
+

+ Complete workouts to earn your first achievement! +

+
+
+ ); + } + + return ( +
+

Achievements

+
+ {achievements.map((achievement) => ( + + ))} +
+ + {/* Achievement Stats */} +
+
+ Total Achievements: {achievements.length} + Total Earned: {achievements.reduce((sum, achievement) => sum + achievement.count, 0)} +
+
+
+ ); +}; + +export default AchievementsSection; diff --git a/app/src/lib/achievementService.ts b/app/src/lib/achievementService.ts new file mode 100644 index 0000000..26f2cfa --- /dev/null +++ b/app/src/lib/achievementService.ts @@ -0,0 +1,214 @@ +import { PrismaClient, AchievementType } from '@prisma/client'; + +const prisma = new PrismaClient(); + +export interface StreakData { + currentStreak: number; + longestStreak: number; + lastWorkoutDate: Date | null; +} + +export class AchievementService { + /** + * Calculate workout streaks for a user + */ + static async calculateStreaks(userId: string): Promise { + // Get all activities for the user, ordered by date + const activities = await prisma.activity.findMany({ + where: { userId }, + orderBy: { date: 'desc' }, + select: { date: true } + }); + + if (activities.length === 0) { + return { + currentStreak: 0, + longestStreak: 0, + lastWorkoutDate: null + }; + } + + // Get unique workout dates + const workoutDates = [...new Set(activities.map(a => a.date.toDateString()))] + .map(dateStr => new Date(dateStr)) + .sort((a, b) => b.getTime() - a.getTime()); + + let currentStreak = 0; + let longestStreak = 0; + let tempStreak = 0; + const today = new Date(); + today.setHours(0, 0, 0, 0); + + // Calculate current streak + for (let i = 0; i < workoutDates.length; i++) { + const workoutDate = new Date(workoutDates[i]); + workoutDate.setHours(0, 0, 0, 0); + + const expectedDate = new Date(today); + expectedDate.setDate(today.getDate() - i); + + if (workoutDate.getTime() === expectedDate.getTime()) { + currentStreak++; + } else { + break; + } + } + + // Calculate longest streak + for (let i = 0; i < workoutDates.length; i++) { + if (i === 0) { + tempStreak = 1; + } else { + const currentDate = new Date(workoutDates[i]); + const previousDate = new Date(workoutDates[i - 1]); + + const daysDiff = Math.floor( + (previousDate.getTime() - currentDate.getTime()) / (1000 * 60 * 60 * 24) + ); + + if (daysDiff === 1) { + tempStreak++; + } else { + longestStreak = Math.max(longestStreak, tempStreak); + tempStreak = 1; + } + } + } + longestStreak = Math.max(longestStreak, tempStreak); + + return { + currentStreak, + longestStreak, + lastWorkoutDate: workoutDates[0] || null + }; + } + + /** + * Check and award streak achievements + */ + static async checkStreakAchievements(userId: string): Promise { + const streakData = await this.calculateStreaks(userId); + + // Define streak thresholds and their corresponding achievement types + const streakThresholds = [ + { days: 3, type: AchievementType.STREAK_3_DAYS }, + { days: 5, type: AchievementType.STREAK_5_DAYS }, + { days: 7, type: AchievementType.STREAK_7_DAYS }, + { days: 14, type: AchievementType.STREAK_14_DAYS }, + { days: 30, type: AchievementType.STREAK_30_DAYS } + ]; + + // Check if user has achieved any streak milestones + for (const threshold of streakThresholds) { + if (streakData.currentStreak >= threshold.days) { + await this.awardAchievement(userId, threshold.type); + } + } + } + + /** + * Check and award consistency achievements (4+ days in a week) + */ + static async checkConsistencyAchievements(userId: string): Promise { + const oneWeekAgo = new Date(); + oneWeekAgo.setDate(oneWeekAgo.getDate() - 7); + + // Get activities from the last week + const weeklyActivities = await prisma.activity.findMany({ + where: { + userId, + date: { + gte: oneWeekAgo + } + }, + select: { date: true } + }); + + // Count unique days with activities + const uniqueDays = new Set( + weeklyActivities.map(activity => activity.date.toDateString()) + ).size; + + // Award consistency achievement if 4+ days + if (uniqueDays >= 4) { + await this.awardAchievement(userId, AchievementType.CONSISTENCY_CHAMPION); + } + } + + /** + * Check and award first workout achievement + */ + static async checkFirstWorkoutAchievement(userId: string): Promise { + const activityCount = await prisma.activity.count({ + where: { userId } + }); + + if (activityCount >= 1) { + await this.awardAchievement(userId, AchievementType.FIRST_WORKOUT); + } + } + + /** + * Check and award workout marathon achievement (26 workouts) + */ + static async checkWorkoutMarathonAchievement(userId: string): Promise { + const activityCount = await prisma.activity.count({ + where: { userId } + }); + + if (activityCount >= 26) { + await this.awardAchievement(userId, AchievementType.WORKOUT_MARATHON); + } + } + + /** + * Award an achievement to a user + */ + static async awardAchievement(userId: string, achievementType: AchievementType): Promise { + try { + // Check if achievement already exists + const existingAchievement = await prisma.achievement.findUnique({ + where: { + userId_type: { + userId, + type: achievementType + } + } + }); + + if (existingAchievement) { + // Increment count and update earned date + await prisma.achievement.update({ + where: { id: existingAchievement.id }, + data: { + count: existingAchievement.count + 1, + earnedAt: new Date() + } + }); + } else { + // Create new achievement + await prisma.achievement.create({ + data: { + userId, + type: achievementType, + count: 1 + } + }); + } + } catch (error) { + console.error(`Error awarding achievement ${achievementType} to user ${userId}:`, error); + } + } + + /** + * Check all achievements for a user (call this after each workout) + */ + static async checkAllAchievements(userId: string): Promise { + await Promise.all([ + this.checkStreakAchievements(userId), + this.checkConsistencyAchievements(userId), + this.checkFirstWorkoutAchievement(userId), + this.checkWorkoutMarathonAchievement(userId) + ]); + } +} diff --git a/app/src/tests/AchievementSystem.test.tsx b/app/src/tests/AchievementSystem.test.tsx new file mode 100644 index 0000000..e81ca01 --- /dev/null +++ b/app/src/tests/AchievementSystem.test.tsx @@ -0,0 +1,87 @@ +import { AchievementType } from '@prisma/client'; + +describe('Achievement System', () => { + describe('Achievement Types', () => { + it('should have all required achievement types', () => { + expect(AchievementType.STREAK_3_DAYS).toBe('STREAK_3_DAYS'); + expect(AchievementType.STREAK_5_DAYS).toBe('STREAK_5_DAYS'); + expect(AchievementType.STREAK_7_DAYS).toBe('STREAK_7_DAYS'); + expect(AchievementType.STREAK_14_DAYS).toBe('STREAK_14_DAYS'); + expect(AchievementType.STREAK_30_DAYS).toBe('STREAK_30_DAYS'); + expect(AchievementType.FIRST_WORKOUT).toBe('FIRST_WORKOUT'); + expect(AchievementType.WORKOUT_MARATHON).toBe('WORKOUT_MARATHON'); + expect(AchievementType.CONSISTENCY_CHAMPION).toBe('CONSISTENCY_CHAMPION'); + }); + }); + + describe('Achievement Logic', () => { + it('should calculate streak correctly for consecutive days', () => { + const today = new Date(); + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + const twoDaysAgo = new Date(today); + twoDaysAgo.setDate(twoDaysAgo.getDate() - 2); + + // Test that we can create date arrays for streak calculation + const activities = [today, yesterday, twoDaysAgo]; + const uniqueDates = [...new Set(activities.map(date => date.toDateString()))]; + + expect(uniqueDates.length).toBe(3); + }); + + it('should handle broken streaks correctly', () => { + const today = new Date(); + const threeDaysAgo = new Date(today); + threeDaysAgo.setDate(threeDaysAgo.getDate() - 3); + const fourDaysAgo = new Date(today); + fourDaysAgo.setDate(fourDaysAgo.getDate() - 4); + + const activities = [today, threeDaysAgo, fourDaysAgo]; + const uniqueDates = [...new Set(activities.map(date => date.toDateString()))]; + + expect(uniqueDates.length).toBe(3); + }); + + it('should calculate weekly consistency correctly', () => { + const oneWeekAgo = new Date(); + oneWeekAgo.setDate(oneWeekAgo.getDate() - 7); + + // Create activities for 4 different days in the last week + const activities = [ + { date: new Date(oneWeekAgo.getTime() + 1 * 24 * 60 * 60 * 1000) }, + { date: new Date(oneWeekAgo.getTime() + 2 * 24 * 60 * 60 * 1000) }, + { date: new Date(oneWeekAgo.getTime() + 3 * 24 * 60 * 60 * 1000) }, + { date: new Date(oneWeekAgo.getTime() + 4 * 24 * 60 * 60 * 1000) }, + ]; + + const uniqueDays = new Set( + activities.map(activity => activity.date.toDateString()) + ).size; + + expect(uniqueDays).toBe(4); + expect(uniqueDays >= 4).toBe(true); // Should qualify for consistency achievement + }); + }); + + describe('Achievement Definitions', () => { + it('should have proper achievement metadata structure', () => { + const mockAchievement = { + id: 'test-achievement', + name: 'Streak Starter', + description: 'Work out for 3 days in a row', + icon: '🔥', + count: 1, + color: '#ff6b35', + gradient: 'linear-gradient(135deg, #ff6b35, #f7931e)', + earnedAt: new Date() + }; + + expect(mockAchievement.name).toBe('Streak Starter'); + expect(mockAchievement.description).toBe('Work out for 3 days in a row'); + expect(mockAchievement.icon).toBe('🔥'); + expect(mockAchievement.count).toBe(1); + expect(mockAchievement.color).toBe('#ff6b35'); + expect(mockAchievement.gradient).toContain('linear-gradient'); + }); + }); +}); diff --git a/app/test-results.junit.xml b/app/test-results.junit.xml index 23ea9c4..f3379f8 100644 --- a/app/test-results.junit.xml +++ b/app/test-results.junit.xml @@ -1,17 +1,15 @@ - - - + + + - + - + - + - - - + \ No newline at end of file