diff --git a/apps/admin/data/fitai.db b/apps/admin/data/fitai.db index 81e63f7..8a60006 100644 Binary files a/apps/admin/data/fitai.db and b/apps/admin/data/fitai.db differ diff --git a/apps/admin/src/app/api/recommendations/generate/route.ts b/apps/admin/src/app/api/recommendations/generate/route.ts index 82db94c..cd78bd8 100644 --- a/apps/admin/src/app/api/recommendations/generate/route.ts +++ b/apps/admin/src/app/api/recommendations/generate/route.ts @@ -6,7 +6,7 @@ import log from "@/lib/logger"; export async function POST(req: Request) { try { - const { userId, useExternalModel } = await req.json(); + const { userId, useExternalModel, modelProvider } = await req.json(); if (!userId) { return NextResponse.json( @@ -43,7 +43,78 @@ export async function POST(req: Request) { let parsedResponse; - if (useExternalModel) { + // Determine which AI provider to use + const provider = + modelProvider || (useExternalModel ? "deepseek" : "ollama"); + + if (provider === "openai") { + // Use OpenAI + const openaiApiKey = process.env.OPENAI_API_KEY; + + if (!openaiApiKey) { + return NextResponse.json( + { error: "OpenAI API key not configured" }, + { status: 500 }, + ); + } + + log.debug("Using OpenAI model", { userId }); + + const openaiResponse = await fetch( + "https://api.openai.com/v1/chat/completions", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${openaiApiKey}`, + }, + body: JSON.stringify({ + model: "gpt-4o-mini", + messages: [ + { + role: "system", + content: + 'You are a professional fitness trainer and nutritionist. Always respond with valid JSON only, no markdown or code blocks. The response must have this exact structure: {"recommendationText": "string", "activityPlan": "string", "dietPlan": "string"}', + }, + { + role: "user", + content: prompt, + }, + ], + temperature: 0.7, + max_tokens: 1500, + response_format: { type: "json_object" }, + }), + }, + ); + + if (!openaiResponse.ok) { + const errorText = await openaiResponse.text(); + log.error("OpenAI API request failed", new Error(errorText), { + status: openaiResponse.status, + }); + return NextResponse.json( + { error: "Failed to generate recommendation from OpenAI" }, + { status: 500 }, + ); + } + + const openaiData = await openaiResponse.json(); + log.debug("Received OpenAI response", { openaiData }); + + try { + const content = openaiData.choices[0].message.content; + parsedResponse = JSON.parse(content); + } catch (e) { + log.error("Failed to parse OpenAI response", e, { + response: openaiData, + }); + return NextResponse.json( + { error: "Invalid response format from OpenAI" }, + { status: 500 }, + ); + } + } else if (provider === "deepseek") { // Use DeepSeek AI const deepseekApiKey = process.env.DEEPSEEK_API_KEY; @@ -203,11 +274,12 @@ export async function POST(req: Request) { id: crypto.randomUUID(), userId, fitnessProfileId: profile.id, - type: "ai_plan", recommendationText: parsedResponse.recommendationText, activityPlan: parsedResponse.activityPlan, dietPlan: parsedResponse.dietPlan, status: "pending", + generatedAt: new Date(), + updatedAt: new Date(), }); return NextResponse.json(recommendation); diff --git a/apps/admin/src/app/api/users/statistics/route.ts b/apps/admin/src/app/api/users/statistics/route.ts new file mode 100644 index 0000000..43a7eb6 --- /dev/null +++ b/apps/admin/src/app/api/users/statistics/route.ts @@ -0,0 +1,295 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@clerk/nextjs/server"; +import Database from "better-sqlite3"; +import log from "@/lib/logger"; + +const db = new Database("./data/fitai.db"); + +interface WeeklyStats { + week: string; // ISO week start date + checkIns: number; + goalsCompleted: number; + avgProgress: number; +} + +interface GoalStats { + total: number; + active: number; + completed: number; + avgProgress: number; + byType: Record; +} + +interface AttendanceStats { + totalCheckIns: number; + currentStreak: number; + longestStreak: number; + thisWeek: number; + thisMonth: number; + recentCheckIns: Array<{ + id: string; + checkInTime: string; + checkOutTime: string | null; + type: string; + duration?: number; // in minutes + }>; +} + +interface DashboardStatistics { + goals: GoalStats; + attendance: AttendanceStats; + weeklyTrend: WeeklyStats[]; +} + +// GET - Fetch dashboard statistics for authenticated user +export async function GET(request: NextRequest) { + try { + const { userId } = await auth(); + + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + log.debug("Fetching statistics for user", { userId }); + + // Get goal statistics + const goals = db + .prepare( + `SELECT + goal_type as goalType, + status, + progress, + target_value as targetValue, + current_value as currentValue + FROM fitness_goals + WHERE user_id = ?`, + ) + .all(userId) as Array<{ + goalType: string; + status: string; + progress: number; + targetValue: number | null; + currentValue: number | null; + }>; + + const activeGoals = goals.filter((g) => g.status === "active"); + const completedGoals = goals.filter((g) => g.status === "completed"); + + const goalsByType: Record = {}; + goals.forEach((goal) => { + goalsByType[goal.goalType] = (goalsByType[goal.goalType] || 0) + 1; + }); + + const avgProgress = + activeGoals.length > 0 + ? activeGoals.reduce((sum, g) => sum + (g.progress || 0), 0) / + activeGoals.length + : 0; + + const goalStats: GoalStats = { + total: goals.length, + active: activeGoals.length, + completed: completedGoals.length, + avgProgress: Math.round(avgProgress), + byType: goalsByType, + }; + + // Get attendance statistics + const attendanceRecords = db + .prepare( + `SELECT + id, + check_in_time as checkInTime, + check_out_time as checkOutTime, + type + FROM attendance + WHERE user_id = ? + ORDER BY check_in_time DESC`, + ) + .all(userId) as Array<{ + id: string; + checkInTime: string; + checkOutTime: string | null; + type: string; + }>; + + const totalCheckIns = attendanceRecords.length; + + // Get recent check-ins (last 10) + const recentCheckIns = attendanceRecords.slice(0, 10).map((record) => { + let duration: number | undefined; + if (record.checkOutTime) { + const checkIn = new Date(record.checkInTime).getTime(); + const checkOut = new Date(record.checkOutTime).getTime(); + duration = Math.round((checkOut - checkIn) / (1000 * 60)); // minutes + } + return { + ...record, + duration, + }; + }); + + // Calculate this week's check-ins + const now = new Date(); + const startOfWeek = new Date(now); + startOfWeek.setDate(now.getDate() - now.getDay()); // Sunday + startOfWeek.setHours(0, 0, 0, 0); + + const thisWeekCheckIns = attendanceRecords.filter( + (r) => new Date(r.checkInTime) >= startOfWeek, + ).length; + + // Calculate this month's check-ins + const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); + const thisMonthCheckIns = attendanceRecords.filter( + (r) => new Date(r.checkInTime) >= startOfMonth, + ).length; + + // Calculate current streak (consecutive days with check-ins) + let currentStreak = 0; + let longestStreak = 0; + let tempStreak = 0; + let lastDate: Date | null = null; + + const sortedRecords = [...attendanceRecords].sort( + (a, b) => + new Date(b.checkInTime).getTime() - new Date(a.checkInTime).getTime(), + ); + + const uniqueDays = new Set(); + sortedRecords.forEach((record) => { + const date = new Date(record.checkInTime); + const dateStr = date.toISOString().split("T")[0]; + uniqueDays.add(dateStr); + }); + + const sortedDays = Array.from(uniqueDays).sort().reverse(); + + for (let i = 0; i < sortedDays.length; i++) { + const currentDate = new Date(sortedDays[i]); + + if (lastDate === null) { + tempStreak = 1; + currentStreak = 1; + } else { + const dayDiff = Math.floor( + (lastDate.getTime() - currentDate.getTime()) / (1000 * 60 * 60 * 24), + ); + + if (dayDiff === 1) { + tempStreak++; + if (i === 0 || currentStreak > 0) { + currentStreak = tempStreak; + } + } else { + if (currentStreak === 0) { + currentStreak = 0; + } + tempStreak = 1; + } + } + + longestStreak = Math.max(longestStreak, tempStreak); + lastDate = currentDate; + } + + // Check if streak is still active (last check-in was today or yesterday) + if (sortedDays.length > 0) { + const lastCheckIn = new Date(sortedDays[0]); + const today = new Date(); + today.setHours(0, 0, 0, 0); + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + + lastCheckIn.setHours(0, 0, 0, 0); + + if ( + lastCheckIn.getTime() !== today.getTime() && + lastCheckIn.getTime() !== yesterday.getTime() + ) { + currentStreak = 0; + } + } + + const attendanceStats: AttendanceStats = { + totalCheckIns, + currentStreak, + longestStreak, + thisWeek: thisWeekCheckIns, + thisMonth: thisMonthCheckIns, + recentCheckIns, + }; + + // Calculate weekly trend (last 8 weeks) + const weeklyTrend: WeeklyStats[] = []; + for (let i = 7; i >= 0; i--) { + const weekStart = new Date(now); + weekStart.setDate(now.getDate() - now.getDay() - i * 7); + weekStart.setHours(0, 0, 0, 0); + + const weekEnd = new Date(weekStart); + weekEnd.setDate(weekStart.getDate() + 7); + + const weekCheckIns = attendanceRecords.filter((r) => { + const checkInDate = new Date(r.checkInTime); + return checkInDate >= weekStart && checkInDate < weekEnd; + }).length; + + const weekGoalsCompleted = db + .prepare( + `SELECT COUNT(*) as count + FROM fitness_goals + WHERE user_id = ? + AND status = 'completed' + AND completed_date >= ? + AND completed_date < ?`, + ) + .get(userId, weekStart.toISOString(), weekEnd.toISOString()) as { + count: number; + }; + + const weekActiveGoals = db + .prepare( + `SELECT progress + FROM fitness_goals + WHERE user_id = ? + AND status = 'active' + AND created_at < ?`, + ) + .all(userId, weekEnd.toISOString()) as Array<{ progress: number }>; + + const weekAvgProgress = + weekActiveGoals.length > 0 + ? weekActiveGoals.reduce((sum, g) => sum + (g.progress || 0), 0) / + weekActiveGoals.length + : 0; + + weeklyTrend.push({ + week: weekStart.toISOString().split("T")[0], + checkIns: weekCheckIns, + goalsCompleted: weekGoalsCompleted.count, + avgProgress: Math.round(weekAvgProgress), + }); + } + + const statistics: DashboardStatistics = { + goals: goalStats, + attendance: attendanceStats, + weeklyTrend, + }; + + log.debug("Statistics calculated successfully", { + userId, + totalGoals: goalStats.total, + totalCheckIns, + }); + + return NextResponse.json({ statistics }); + } catch (error) { + log.error("Failed to fetch statistics", error); + return NextResponse.json( + { error: "Failed to fetch statistics" }, + { status: 500 }, + ); + } +} diff --git a/apps/admin/src/app/recommendations/page.tsx b/apps/admin/src/app/recommendations/page.tsx index f78b8ff..371b131 100644 --- a/apps/admin/src/app/recommendations/page.tsx +++ b/apps/admin/src/app/recommendations/page.tsx @@ -39,15 +39,17 @@ export default function RecommendationsPage() { const fetchData = async () => { try { - // Fetch users + // Fetch users - API returns { success: true, data: { users: [...] }, meta: {...} } const usersRes = await fetch("/api/users"); - const usersData = await usersRes.json(); - setUsers(usersData.users || []); + const usersResult = await usersRes.json(); + const usersArray = usersResult.data?.users || usersResult.users || []; + setUsers(usersArray); - // Fetch pending recommendations + // Fetch pending recommendations - API returns { success: true, data: { recommendations: [...] }, meta: {...} } const recsRes = await fetch("/api/recommendations"); - const recsData = await recsRes.json(); - const allRecs = recsData.recommendations || []; + const recsResult = await recsRes.json(); + const allRecs = + recsResult.data?.recommendations || recsResult.recommendations || []; setPendingRecommendations( allRecs.filter((r: Recommendation) => r.status === "pending"), ); diff --git a/apps/admin/src/components/users/Recommendations.tsx b/apps/admin/src/components/users/Recommendations.tsx index 48651c2..cade2d4 100644 --- a/apps/admin/src/components/users/Recommendations.tsx +++ b/apps/admin/src/components/users/Recommendations.tsx @@ -9,12 +9,16 @@ import { toast } from "@/lib/toast"; interface Recommendation { id: string; userId: string; - type: "short_term" | "medium_term" | "long_term" | "ai_plan"; + fitnessProfileId: string; recommendationText: string; - activityPlan?: string; - dietPlan?: string; - status: "pending" | "completed" | "approved" | "rejected"; + activityPlan: string; + dietPlan: string; + status: "pending" | "approved" | "rejected"; + generatedAt: string; + approvedAt?: string | null; + approvedBy?: string | null; createdAt: string; + updatedAt: string; } interface RecommendationsProps { @@ -24,10 +28,7 @@ interface RecommendationsProps { export function Recommendations({ userId }: RecommendationsProps) { const [recommendations, setRecommendations] = useState([]); const [loading, setLoading] = useState(true); - const [newRec, setNewRec] = useState<{ - type: "short_term" | "medium_term" | "long_term"; - content: string; - }>({ type: "short_term", content: "" }); + const [generating, setGenerating] = useState(false); useEffect(() => { fetchRecommendations(); @@ -38,8 +39,13 @@ export function Recommendations({ userId }: RecommendationsProps) { try { const response = await fetch(`/api/recommendations?userId=${userId}`); if (response.ok) { - const data = await response.json(); - setRecommendations(data); + const result = await response.json(); + // API returns { success: true, data: [...], meta: {...} } + // Extract the recommendations array from the data field + const recsArray = Array.isArray(result.data) + ? result.data + : result.data?.recommendations || []; + setRecommendations(recsArray); } } catch (error) { log.error("Failed to fetch recommendations", error); @@ -48,159 +54,179 @@ export function Recommendations({ userId }: RecommendationsProps) { } }; - const handleAddRecommendation = async (e: React.FormEvent) => { - e.preventDefault(); + const handleGenerateRecommendation = async () => { + setGenerating(true); try { - const response = await fetch("/api/recommendations", { + const response = await fetch("/api/recommendations/generate", { method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ userId }), + }); + + if (response.ok) { + fetchRecommendations(); + toast.success("AI recommendation generated successfully"); + } else { + const error = await response.json(); + toast.error(error.error || "Failed to generate recommendation"); + } + } catch (error) { + log.error("Failed to generate recommendation", error); + toast.error("Failed to generate recommendation"); + } finally { + setGenerating(false); + } + }; + + const handleApproveRecommendation = async (recommendationId: string) => { + try { + const response = await fetch("/api/recommendations/approve", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ recommendationId }), + }); + + if (response.ok) { + fetchRecommendations(); + toast.success("Recommendation approved"); + } else { + toast.error("Failed to approve recommendation"); + } + } catch (error) { + log.error("Failed to approve recommendation", error); + toast.error("Failed to approve recommendation"); + } + }; + + const handleRejectRecommendation = async (recommendationId: string) => { + try { + const response = await fetch("/api/recommendations", { + method: "PUT", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - userId, - type: newRec.type, - content: newRec.content, + recommendationId, + status: "rejected", }), }); if (response.ok) { - setNewRec({ ...newRec, content: "" }); fetchRecommendations(); - toast.success("Recommendation added successfully"); + toast.success("Recommendation rejected"); } else { - toast.error("Failed to add recommendation"); + toast.error("Failed to reject recommendation"); } } catch (error) { - log.error("Failed to add recommendation", error); + log.error("Failed to reject recommendation", error); + toast.error("Failed to reject recommendation"); } }; - const groupedRecs = { - ai_plan: recommendations.filter((r) => r.type === "ai_plan"), - short_term: recommendations.filter((r) => r.type === "short_term"), - medium_term: recommendations.filter((r) => r.type === "medium_term"), - long_term: recommendations.filter((r) => r.type === "long_term"), - }; - - const renderSection = ( - title: string, - type: "short_term" | "medium_term" | "long_term" | "ai_plan", - items: Recommendation[], - ) => ( -
-

{title}

-
- {items.length === 0 && ( -

- No recommendations yet. -

- )} - {items.map((rec) => ( -
-
-

{rec.recommendationText}

- {rec.type === "ai_plan" && ( -
- {rec.activityPlan && ( -

- Activity:{" "} - {rec.activityPlan} -

- )} - {rec.dietPlan && ( -

- Diet:{" "} - {rec.dietPlan} -

- )} -
- )} -

- {new Date(rec.createdAt).toLocaleDateString()} -{" "} - - {rec.status === "completed" - ? "Completed" - : rec.status === "approved" - ? "Approved" - : "Pending"} - -

-
-
- ))} -
- {type !== "ai_plan" && ( -
- setNewRec({ ...newRec, type: type as any })} - /> - {newRec.type === type && ( - <> - - setNewRec({ ...newRec, content: e.target.value }) - } - required - /> - - - )} - {newRec.type !== type && ( - - )} -
- )} -
- ); - if (loading) return
Loading recommendations...
; return ( - -

Fitness Recommendations

+ +

AI Fitness Recommendations

+
-
- {renderSection("Daily AI Plan", "ai_plan", groupedRecs.ai_plan)} - {renderSection( - "Short Term Goals", - "short_term", - groupedRecs.short_term, - )} - {renderSection( - "Medium Term Goals", - "medium_term", - groupedRecs.medium_term, - )} - {renderSection("Long Term Goals", "long_term", groupedRecs.long_term)} -
+ {recommendations.length === 0 ? ( +

+ No recommendations yet. Click "Generate New Recommendation" to + create one. +

+ ) : ( +
+ {recommendations.map((rec) => ( +
+
+
+

+ Recommendation +

+

{rec.recommendationText}

+
+ + {rec.activityPlan && ( +
+

+ Activity Plan +

+

+ {rec.activityPlan} +

+
+ )} + + {rec.dietPlan && ( +
+

+ Diet Plan +

+

{rec.dietPlan}

+
+ )} + +
+
+ Generated: {new Date(rec.generatedAt).toLocaleString()} + {rec.approvedAt && ( + <> + {" "} + • Approved:{" "} + {new Date(rec.approvedAt).toLocaleString()} + + )} +
+
+ + {rec.status.charAt(0).toUpperCase() + + rec.status.slice(1)} + + {rec.status === "pending" && ( +
+ + +
+ )} +
+
+
+
+ ))} +
+ )}
); diff --git a/apps/mobile/package-lock.json b/apps/mobile/package-lock.json index a7bec03..038822f 100644 --- a/apps/mobile/package-lock.json +++ b/apps/mobile/package-lock.json @@ -37,9 +37,10 @@ "react-dom": "^19.1.0", "react-hook-form": "^7.47.0", "react-native": "0.81.5", + "react-native-chart-kit": "^6.12.0", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", - "react-native-svg": "^15.15.0", + "react-native-svg": "^15.15.3", "react-native-web": "^0.21.2", "zod": "^3.22.0" }, @@ -11256,6 +11257,15 @@ "node": ">=8" } }, + "node_modules/paths-js": { + "version": "0.4.11", + "resolved": "https://registry.npmjs.org/paths-js/-/paths-js-0.4.11.tgz", + "integrity": "sha512-3mqcLomDBXOo7Fo+UlaenG6f71bk1ZezPQy2JCmYHy2W2k5VKpP+Jbin9H0bjXynelTbglCqdFhSEkeIkKTYUA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.11.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -11375,6 +11385,12 @@ "node": ">=4.0.0" } }, + "node_modules/point-in-polygon": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/point-in-polygon/-/point-in-polygon-1.1.0.tgz", + "integrity": "sha512-3ojrFwjnnw8Q9242TzgXuTD+eKiutbzyslcq1ydfu82Db2y+Ogbmyrkpv0Hgj31qwT3lbS9+QAAO/pIQM35XRw==", + "license": "MIT" + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -11780,6 +11796,22 @@ } } }, + "node_modules/react-native-chart-kit": { + "version": "6.12.0", + "resolved": "https://registry.npmjs.org/react-native-chart-kit/-/react-native-chart-kit-6.12.0.tgz", + "integrity": "sha512-nZLGyCFzZ7zmX0KjYeeSV1HKuPhl1wOMlTAqa0JhlyW62qV/1ZPXHgT8o9s8mkFaGxdqbspOeuaa6I9jUQDgnA==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.13", + "paths-js": "^0.4.10", + "point-in-polygon": "^1.0.1" + }, + "peerDependencies": { + "react": "> 16.7.0", + "react-native": ">= 0.50.0", + "react-native-svg": "> 6.4.1" + } + }, "node_modules/react-native-is-edge-to-edge": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.2.1.tgz", @@ -11816,9 +11848,9 @@ } }, "node_modules/react-native-svg": { - "version": "15.15.0", - "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.15.0.tgz", - "integrity": "sha512-/Wx6F/IZ88B/GcF88bK8K7ZseJDYt+7WGaiggyzLvTowChQ8BM5idmcd4pK+6QJP6a6DmzL2sfOMukFUn/NArg==", + "version": "15.15.3", + "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.15.3.tgz", + "integrity": "sha512-/k4KYwPBLGcx2f5d4FjE+vCScK7QOX14cl2lIASJ28u4slHHtIhL0SZKU7u9qmRBHxTCKPoPBtN6haT1NENJNA==", "license": "MIT", "dependencies": { "css-select": "^5.1.0", diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 49a7fea..9d952d8 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -43,9 +43,10 @@ "react-dom": "^19.1.0", "react-hook-form": "^7.47.0", "react-native": "0.81.5", + "react-native-chart-kit": "^6.12.0", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", - "react-native-svg": "^15.15.0", + "react-native-svg": "^15.15.3", "react-native-web": "^0.21.2", "zod": "^3.22.0" }, diff --git a/apps/mobile/src/api/index.ts b/apps/mobile/src/api/index.ts index 14afaac..9bfa591 100644 --- a/apps/mobile/src/api/index.ts +++ b/apps/mobile/src/api/index.ts @@ -6,3 +6,8 @@ export * from "./types"; export * from "./responses"; +export * from "./statistics"; +export * from "./fitnessProfile"; +export * from "./attendance"; +export * from "./recommendations"; +export * from "./client"; diff --git a/apps/mobile/src/api/recommendations.ts b/apps/mobile/src/api/recommendations.ts new file mode 100644 index 0000000..206f765 --- /dev/null +++ b/apps/mobile/src/api/recommendations.ts @@ -0,0 +1,76 @@ +import { apiClient } from "./client"; +import { API_ENDPOINTS } from "../config/api"; + +export interface Recommendation { + id: string; + userId: string; + fitnessProfileId: string; + recommendationText: string; + activityPlan: string; + dietPlan: string; + status: "pending" | "approved" | "rejected"; + generatedAt: string; + approvedAt?: string | null; + approvedBy?: string | null; + createdAt: string; + updatedAt: string; +} + +export interface GenerateRecommendationRequest { + userId: string; + useExternalModel?: boolean; + modelProvider?: "openai" | "deepseek" | "ollama"; +} + +/** + * Get recommendations for a user + * + * @param userId - Clerk user ID + * @returns Array of recommendations + */ +export async function getRecommendations( + userId: string, +): Promise { + const response = await apiClient.get( + API_ENDPOINTS.RECOMMENDATIONS, + { + params: { userId }, + }, + ); + + return response.data; +} + +/** + * Generate a new AI recommendation for a user + * + * @param data - Generation request data + * @returns The generated recommendation + */ +export async function generateRecommendation( + data: GenerateRecommendationRequest, +): Promise { + const response = await apiClient.post( + `${API_ENDPOINTS.RECOMMENDATIONS}/generate`, + data, + ); + + return response.data; +} + +/** + * Approve a recommendation (admin/trainer only) + * + * @param recommendationId - Recommendation ID + * @returns The approved recommendation + */ +export async function approveRecommendation( + recommendationId: string, +): Promise { + const response = await apiClient.post( + `${API_ENDPOINTS.RECOMMENDATIONS}/approve`, + { id: recommendationId }, + ); + + return response.data; +} diff --git a/apps/mobile/src/api/statistics.ts b/apps/mobile/src/api/statistics.ts new file mode 100644 index 0000000..ef81589 --- /dev/null +++ b/apps/mobile/src/api/statistics.ts @@ -0,0 +1,22 @@ +import { apiClient } from "./client"; +import { API_ENDPOINTS } from "../config/api"; +import type { UserStatisticsResponse } from "./types"; + +/** + * Fetch user statistics including goals, attendance, and weekly trends + * + * @param userId - Clerk user ID + * @returns User statistics data + */ +export async function getUserStatistics( + userId: string, +): Promise { + const response = await apiClient.get( + API_ENDPOINTS.USERS.STATISTICS, + { + params: { userId }, + }, + ); + + return response.data; +} diff --git a/apps/mobile/src/api/types.ts b/apps/mobile/src/api/types.ts index 5376978..6c7da80 100644 --- a/apps/mobile/src/api/types.ts +++ b/apps/mobile/src/api/types.ts @@ -185,6 +185,48 @@ export interface GymResponse { updatedAt: string; } +/** + * Statistics response types + */ +export interface GoalStatistics { + totalGoals: number; + activeGoals: number; + completedGoals: number; + averageProgress: number; + goalsByType: Array<{ + goalType: string; + count: number; + }>; +} + +export interface AttendanceStatistics { + totalCheckIns: number; + currentStreak: number; + longestStreak: number; + checkInsThisWeek: number; + checkInsThisMonth: number; + recentCheckIns: Array<{ + id: string; + checkInTime: string; + checkOutTime?: string | null; + duration?: number | null; + }>; +} + +export interface WeeklyTrendData { + weekLabel: string; + checkIns: number; + goalsCompleted: number; + averageProgress: number; +} + +export interface UserStatisticsResponse { + userId: string; + goals: GoalStatistics; + attendance: AttendanceStatistics; + weeklyTrend: WeeklyTrendData[]; +} + /** * Common error codes */ diff --git a/apps/mobile/src/app/(tabs)/attendance.tsx b/apps/mobile/src/app/(tabs)/attendance.tsx index 183042a..f4e889b 100644 --- a/apps/mobile/src/app/(tabs)/attendance.tsx +++ b/apps/mobile/src/app/(tabs)/attendance.tsx @@ -12,6 +12,7 @@ import { useAuth } from "@clerk/clerk-expo"; import { LinearGradient } from "expo-linear-gradient"; import { Ionicons } from "@expo/vector-icons"; import { attendanceApi, Attendance } from "../../api/attendance"; +import { AttendanceCalendar } from "../../components/AttendanceCalendar"; import { theme } from "../../styles/theme"; import { Animated } from "react-native"; import { getErrorMessage } from "../../utils/error-helpers"; @@ -186,6 +187,9 @@ export default function AttendanceScreen() { )} + {/* Attendance Calendar */} + {history.length > 0 && } + Recent History {history.map((item) => ( ([]); + const [statistics, setStatistics] = useState( + null, + ); const [refreshing, setRefreshing] = useState(false); const [isModalVisible, setIsModalVisible] = useState(false); + const [showAnalytics, setShowAnalytics] = useState(false); const fabScale = useRef(new Animated.Value(1)).current; const loadGoals = useCallback(async () => { @@ -60,6 +68,14 @@ export default function GoalsScreen() { const loadedGoals = await fitnessGoalsService.getGoals(user.id, token); setGoals(loadedGoals); + + // Load statistics + try { + const stats = await getUserStatistics(user.id); + setStatistics(stats); + } catch (error) { + log.error("Failed to load statistics", error); + } } catch (error) { log.error("Failed to load goals", error); } @@ -199,6 +215,47 @@ export default function GoalsScreen() { )} + {/* Analytics Section */} + {statistics && ( + + setShowAnalytics(!showAnalytics)} + > + + + Progress Analytics + + + + + {showAnalytics && ( + + {statistics.weeklyTrend.length > 0 && ( + + )} + {statistics.goals.goalsByType.length > 0 && ( + + )} + + )} + + )} + {/* Active Goals */} @@ -348,6 +405,33 @@ const styles = StyleSheet.create({ color: "#6b7280", fontWeight: "500", }, + analyticsSection: { + padding: 16, + paddingTop: 0, + }, + analyticsHeader: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + backgroundColor: theme.colors.white, + padding: 16, + borderRadius: theme.borderRadius.xl, + marginBottom: 12, + ...theme.shadows.subtle, + }, + analyticsHeaderLeft: { + flexDirection: "row", + alignItems: "center", + gap: 8, + }, + analyticsTitle: { + fontSize: theme.typography.fontSize.base, + fontWeight: theme.typography.fontWeight.semibold, + color: theme.colors.gray700, + }, + analyticsContent: { + paddingTop: 4, + }, section: { padding: 20, paddingTop: 10, diff --git a/apps/mobile/src/app/(tabs)/index.tsx b/apps/mobile/src/app/(tabs)/index.tsx index e0d323f..db2a0a5 100644 --- a/apps/mobile/src/app/(tabs)/index.tsx +++ b/apps/mobile/src/app/(tabs)/index.tsx @@ -1,14 +1,22 @@ -import { View, Text, StyleSheet, ScrollView, RefreshControl, Image } from "react-native"; +import { + View, + Text, + StyleSheet, + ScrollView, + RefreshControl, + Image, +} from "react-native"; import { useUser } from "@clerk/clerk-expo"; import { LinearGradient } from "expo-linear-gradient"; import { useState, useCallback, useEffect } from "react"; -import AsyncStorage from '@react-native-async-storage/async-storage'; +import AsyncStorage from "@react-native-async-storage/async-storage"; import { theme } from "../../styles/theme"; import { ActivityWidget } from "../../components/ActivityWidget"; import { QuickActionGrid } from "../../components/QuickActionGrid"; import { TrackMealModal } from "../../components/TrackMealModal"; import { AddWaterModal } from "../../components/AddWaterModal"; import { HydrationWidget } from "../../components/HydrationWidget"; +import { WeeklyProgressWidget } from "../../components/WeeklyProgressWidget"; import { ScanFoodModal } from "../../components/ScanFoodModal"; import { Ionicons } from "@expo/vector-icons"; @@ -35,13 +43,17 @@ export default function HomeScreen() { return "Good Evening"; }; - const handleSaveMeal = (meal: { type: string; name: string; calories: number }) => { - setCalories(prev => prev + meal.calories); + const handleSaveMeal = (meal: { + type: string; + name: string; + calories: number; + }) => { + setCalories((prev) => prev + meal.calories); setTrackMealModalVisible(false); }; const handleAddWater = (amount: number) => { - setWaterIntake(prev => prev + amount); + setWaterIntake((prev) => prev + amount); setAddWaterModalVisible(false); }; @@ -54,7 +66,7 @@ export default function HomeScreen() { }; const handleAddScannedFood = (scannedCalories: number) => { - setCalories(prev => prev + scannedCalories); + setCalories((prev) => prev + scannedCalories); setScanFoodModalVisible(false); }; @@ -62,13 +74,13 @@ export default function HomeScreen() { setCalories(0); setWaterIntake(0); const today = new Date().toDateString(); - await AsyncStorage.setItem('lastResetDate', today); + await AsyncStorage.setItem("lastResetDate", today); }; // Check for midnight reset useEffect(() => { const checkAndResetIfNeeded = async () => { - const lastResetDate = await AsyncStorage.getItem('lastResetDate'); + const lastResetDate = await AsyncStorage.getItem("lastResetDate"); const today = new Date().toDateString(); if (lastResetDate !== today) { @@ -89,9 +101,12 @@ export default function HomeScreen() { await resetAllCounters(); // Set up daily interval after first midnight - const dailyInterval = setInterval(async () => { - await resetAllCounters(); - }, 24 * 60 * 60 * 1000); // 24 hours + const dailyInterval = setInterval( + async () => { + await resetAllCounters(); + }, + 24 * 60 * 60 * 1000, + ); // 24 hours return () => clearInterval(dailyInterval); }, timeUntilMidnight); @@ -104,7 +119,11 @@ export default function HomeScreen() { + } > {/* Header Section */} @@ -125,24 +144,7 @@ export default function HomeScreen() { {/* Activity Widget */} - - - {/* Hydration Widget */} - - - {/* Quick Actions */} - setTrackMealModalVisible(true)} - onAddWaterPress={() => setAddWaterModalVisible(true)} - onScanFoodPress={() => setScanFoodModalVisible(true)} - /> + @@ -192,7 +194,7 @@ export default function HomeScreen() { diff --git a/apps/mobile/src/app/(tabs)/recommendations.tsx b/apps/mobile/src/app/(tabs)/recommendations.tsx index 4bd5b3b..d3906f5 100644 --- a/apps/mobile/src/app/(tabs)/recommendations.tsx +++ b/apps/mobile/src/app/(tabs)/recommendations.tsx @@ -1,88 +1,101 @@ -import { useEffect, useState } from "react"; +import React, { useState, useCallback } from "react"; import { View, Text, - FlatList, - ActivityIndicator, StyleSheet, + ScrollView, RefreshControl, + TouchableOpacity, + ActivityIndicator, + Alert, } from "react-native"; -import { useAuth } from "@clerk/clerk-expo"; -import { Ionicons } from "@expo/vector-icons"; import { LinearGradient } from "expo-linear-gradient"; -import { API_BASE_URL, API_ENDPOINTS } from "../../config/api"; +import { Ionicons } from "@expo/vector-icons"; +import { useUser } from "@clerk/clerk-expo"; +import { useFocusEffect } from "expo-router"; import { theme } from "../../styles/theme"; +import { + getRecommendations, + generateRecommendation, + type Recommendation, +} from "../../api/recommendations"; import log from "../../utils/logger"; -interface Recommendation { - id: string; - userId: string; - recommendationText: string; - activityPlan?: string; - dietPlan?: string; - status: string; - createdAt: string; -} - export default function RecommendationsScreen() { - const { getToken, userId } = useAuth(); + const { user } = useUser(); const [recommendations, setRecommendations] = useState([]); const [loading, setLoading] = useState(true); + const [generating, setGenerating] = useState(false); const [refreshing, setRefreshing] = useState(false); - const fetchRecommendations = async () => { + const loadRecommendations = useCallback(async () => { + if (!user?.id) return; + try { - if (!userId) { - log.warn("No userId available"); - return; - } - - const token = await getToken(); - const headers: Record = { - "Content-Type": "application/json", - }; - - if (token) { - headers["Authorization"] = `Bearer ${token}`; - } - - const url = `${API_BASE_URL}${API_ENDPOINTS.RECOMMENDATIONS}?userId=${userId}`; - log.apiRequest("GET", url); - - const res = await fetch(url, { headers }); - - if (!res.ok) { - const errorText = await res.text(); - log.error("API error fetching recommendations", { - status: res.status, - errorText, - }); - throw new Error(`Network response was not ok: ${res.status}`); - } - - const data = await res.json(); - log.debug("Recommendations fetched", { - count: data.recommendations?.length || data.length || 0, - }); - setRecommendations(data.recommendations || data || []); - } catch (e) { - log.error("Failed to load recommendations", e); + setLoading(true); + const data = await getRecommendations(user.id); + // Filter to show only approved recommendations for regular users + const approved = data.filter((rec) => rec.status === "approved"); + setRecommendations(approved); + } catch (error) { + log.error("Failed to load recommendations", error); + Alert.alert("Error", "Failed to load recommendations"); } finally { setLoading(false); - setRefreshing(false); } - }; + }, [user?.id]); - useEffect(() => { - fetchRecommendations(); - }, []); + useFocusEffect( + useCallback(() => { + loadRecommendations(); + }, [loadRecommendations]), + ); - const onRefresh = () => { + const onRefresh = async () => { setRefreshing(true); - fetchRecommendations(); + await loadRecommendations(); + setRefreshing(false); }; - if (loading) { + const handleGenerateRecommendation = async () => { + if (!user?.id) return; + + Alert.alert( + "Generate AI Recommendation", + "Generate a personalized fitness and nutrition plan based on your profile and goals?", + [ + { text: "Cancel", style: "cancel" }, + { + text: "Generate", + onPress: async () => { + try { + setGenerating(true); + await generateRecommendation({ + userId: user.id, + modelProvider: "openai", + useExternalModel: true, + }); + Alert.alert( + "Success", + "AI recommendation generated! It will appear here once approved by your trainer.", + ); + await loadRecommendations(); + } catch (error) { + log.error("Failed to generate recommendation", error); + Alert.alert( + "Error", + "Failed to generate recommendation. Please try again.", + ); + } finally { + setGenerating(false); + } + }, + }, + ], + ); + }; + + if (loading && recommendations.length === 0) { return ( @@ -92,101 +105,179 @@ export default function RecommendationsScreen() { return ( - - AI Recommendations - - - {/* AI Context Info Banner with Glassmorphism */} - - - - - - - - Personalized based on your active fitness goals and progress - - - - item.id} + + } - contentContainerStyle={styles.listContent} - ListEmptyComponent={ - - - - - - - No recommendations available yet. - Pull down to refresh + > + {/* Header */} + + + AI Recommendations + + Personalized fitness & nutrition plans + - } - renderItem={({ item }) => ( - + + + + + {/* Generate Button */} + + - + + {generating ? ( + + ) : ( + <> + + + Generate New Plan + + + )} + + + + + {/* Recommendations List */} + + {recommendations.length === 0 ? ( + - - {item.status.toUpperCase()} + + No Recommendations Yet + + Tap "Generate New Plan" to get personalized AI-powered fitness + and nutrition recommendations based on your profile and goals. - - {new Date(item.createdAt).toLocaleDateString()} + + ) : ( + recommendations.map((recommendation) => ( + + )) + )} + + + + ); +} + +interface RecommendationCardProps { + recommendation: Recommendation; +} + +function RecommendationCard({ recommendation }: RecommendationCardProps) { + const [expanded, setExpanded] = useState(false); + + return ( + + + {/* Header */} + + + + + + + AI Fitness Plan + + {new Date(recommendation.generatedAt).toLocaleDateString()} + + setExpanded(!expanded)}> + + + - Daily Advice - {item.recommendationText} + {/* Summary */} + + + {recommendation.recommendationText} + + - {item.activityPlan && ( - <> - Activity Plan - {item.activityPlan} - - )} + {/* Expanded Content */} + {expanded && ( + + {/* Activity Plan */} + + + + Activity Plan + + {recommendation.activityPlan} + - {item.dietPlan && ( - <> - Diet Plan - {item.dietPlan} - - )} - + {/* Diet Plan */} + + + + Diet Plan + + {recommendation.dietPlan} + + )} - /> + ); } @@ -196,10 +287,23 @@ const styles = StyleSheet.create({ flex: 1, backgroundColor: theme.colors.background, }, + centered: { + flex: 1, + justifyContent: "center", + alignItems: "center", + backgroundColor: theme.colors.background, + }, + scrollContent: { + paddingBottom: 100, + }, header: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + padding: 24, paddingTop: 60, paddingBottom: 24, - paddingHorizontal: 24, + marginBottom: 20, borderBottomLeftRadius: theme.borderRadius.xl, borderBottomRightRadius: theme.borderRadius.xl, }, @@ -208,112 +312,126 @@ const styles = StyleSheet.create({ fontWeight: theme.typography.fontWeight.bold, color: theme.colors.white, }, - infoBanner: { + headerSubtitle: { + fontSize: theme.typography.fontSize.base, + color: "rgba(255, 255, 255, 0.9)", + marginTop: 4, + }, + iconContainer: { + backgroundColor: "rgba(255, 255, 255, 0.2)", + width: 64, + height: 64, + borderRadius: 32, + justifyContent: "center", + alignItems: "center", + }, + actionContainer: { + paddingHorizontal: 20, + marginBottom: 20, + }, + generateButton: { flexDirection: "row", alignItems: "center", - marginHorizontal: 16, - marginTop: 16, - marginBottom: 12, - padding: 14, - borderRadius: theme.borderRadius.lg, - borderWidth: 1, - borderColor: "rgba(59, 130, 246, 0.2)", - gap: 10, - }, - infoBannerIconContainer: { - marginRight: 4, - }, - infoBannerIcon: { - width: 32, - height: 32, - borderRadius: 16, justifyContent: "center", + paddingVertical: 16, + paddingHorizontal: 24, + borderRadius: theme.borderRadius.xl, + }, + generateButtonText: { + fontSize: theme.typography.fontSize.lg, + fontWeight: theme.typography.fontWeight.bold, + color: theme.colors.white, + }, + section: { + paddingHorizontal: 20, + }, + emptyState: { + paddingVertical: 40, + }, + emptyCard: { + borderRadius: theme.borderRadius["2xl"], + padding: 32, alignItems: "center", }, - infoBannerText: { - flex: 1, - fontSize: theme.typography.fontSize.sm, + emptyTitle: { + fontSize: theme.typography.fontSize.xl, + fontWeight: theme.typography.fontWeight.bold, color: theme.colors.gray700, - lineHeight: 18, - fontWeight: theme.typography.fontWeight.medium, + marginTop: 16, + marginBottom: 8, }, - centered: { - flex: 1, - justifyContent: "center", - alignItems: "center", - backgroundColor: theme.colors.background, - }, - listContent: { - padding: 16, + emptyText: { + fontSize: theme.typography.fontSize.base, + color: theme.colors.gray500, + textAlign: "center", + lineHeight: 24, }, card: { - padding: 18, - marginBottom: 14, - borderRadius: theme.borderRadius.xl, - borderWidth: 1, - borderColor: "rgba(59, 130, 246, 0.1)", + marginBottom: 16, + }, + cardContent: { + borderRadius: theme.borderRadius["2xl"], + padding: 20, }, cardHeader: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", - marginBottom: 14, - paddingBottom: 12, - borderBottomWidth: 1, - borderBottomColor: theme.colors.gray200, - }, - statusBadge: { - paddingHorizontal: 10, - paddingVertical: 5, - borderRadius: theme.borderRadius.md, - }, - statusText: { - fontSize: theme.typography.fontSize.xs, - fontWeight: theme.typography.fontWeight.semibold, - color: theme.colors.white, - }, - date: { - fontSize: theme.typography.fontSize.xs, - color: theme.colors.gray600, - fontWeight: theme.typography.fontWeight.medium, - }, - sectionTitle: { - fontSize: theme.typography.fontSize.base, - fontWeight: theme.typography.fontWeight.semibold, - color: theme.colors.gray900, - marginTop: 12, - marginBottom: 6, - }, - content: { - fontSize: theme.typography.fontSize.sm, - color: theme.colors.gray700, - lineHeight: 20, - }, - emptyContainer: { - alignItems: "center", - justifyContent: "center", - paddingVertical: 60, - }, - emptyIconContainer: { marginBottom: 16, }, - emptyIconGradient: { - width: 96, - height: 96, - borderRadius: 48, + cardHeaderLeft: { + flexDirection: "row", + alignItems: "center", + gap: 12, + }, + cardIcon: { + width: 40, + height: 40, + borderRadius: 20, justifyContent: "center", alignItems: "center", }, - empty: { - textAlign: "center", - fontSize: theme.typography.fontSize.base, - color: theme.colors.gray700, - fontWeight: theme.typography.fontWeight.semibold, - marginBottom: 4, + cardTitle: { + fontSize: theme.typography.fontSize.lg, + fontWeight: theme.typography.fontWeight.bold, + color: theme.colors.gray800, }, - emptySub: { - textAlign: "center", + cardDate: { fontSize: theme.typography.fontSize.sm, color: theme.colors.gray500, + marginTop: 2, + }, + cardSummary: { + marginBottom: 12, + }, + summaryText: { + fontSize: theme.typography.fontSize.base, + color: theme.colors.gray700, + lineHeight: 24, + }, + expandedContent: { + marginTop: 12, + paddingTop: 16, + borderTopWidth: 1, + borderTopColor: theme.colors.gray200, + }, + planSection: { + marginBottom: 16, + }, + planHeader: { + flexDirection: "row", + alignItems: "center", + gap: 8, + marginBottom: 8, + }, + planTitle: { + fontSize: theme.typography.fontSize.base, + fontWeight: theme.typography.fontWeight.bold, + color: theme.colors.gray800, + }, + planText: { + fontSize: theme.typography.fontSize.base, + color: theme.colors.gray600, + lineHeight: 22, }, }); diff --git a/apps/mobile/src/components/ActivityWidget.tsx b/apps/mobile/src/components/ActivityWidget.tsx index 3c1e387..30bc384 100644 --- a/apps/mobile/src/components/ActivityWidget.tsx +++ b/apps/mobile/src/components/ActivityWidget.tsx @@ -1,157 +1,243 @@ -import React from 'react'; -import { View, Text, StyleSheet, Dimensions } from 'react-native'; -import { LinearGradient } from 'expo-linear-gradient'; -import { Ionicons } from '@expo/vector-icons'; -import { theme } from '../styles/theme'; +import React, { useState, useEffect } from "react"; +import { + View, + Text, + StyleSheet, + Dimensions, + ActivityIndicator, +} from "react-native"; +import { LinearGradient } from "expo-linear-gradient"; +import { Ionicons } from "@expo/vector-icons"; +import { useUser } from "@clerk/clerk-expo"; +import { theme } from "../styles/theme"; +import { getUserStatistics } from "../api/statistics"; +import type { UserStatisticsResponse } from "../api/types"; -const { width } = Dimensions.get('window'); +const { width } = Dimensions.get("window"); interface ActivityWidgetProps { - steps: number; - calories: number; - duration: number; // in minutes + steps?: number; + calories: number; + duration?: number; // in minutes } -export function ActivityWidget({ steps, calories, duration }: ActivityWidgetProps) { - return ( - - - - Daily Activity - - +export function ActivityWidget({ + steps = 0, + calories, + duration = 0, +}: ActivityWidgetProps) { + const { user } = useUser(); + const [statistics, setStatistics] = useState( + null, + ); + const [loading, setLoading] = useState(true); - - - - - - {steps.toLocaleString()} - Steps - + useEffect(() => { + const loadStatistics = async () => { + if (!user?.id) return; - + try { + const stats = await getUserStatistics(user.id); + setStatistics(stats); + } catch (error) { + console.error("Failed to load statistics:", error); + } finally { + setLoading(false); + } + }; - - - - - {calories} - Kcal - + loadStatistics(); + }, [user?.id]); - + // Calculate weekly activity bars from weekly trend data + const getWeeklyBars = () => { + if (!statistics || statistics.weeklyTrend.length === 0) { + // Fallback mock data + return [0.4, 0.6, 0.3, 0.8, 0.5, 0.9, 0.7]; + } - - - - - {duration}m - Active - - + // Get last 7 weeks and normalize to 0-1 scale + const last7Weeks = statistics.weeklyTrend.slice(-7); + const maxCheckIns = Math.max(...last7Weeks.map((w) => w.checkIns), 1); - {/* Simple Bar Chart Visualization */} - - {[0.4, 0.6, 0.3, 0.8, 0.5, 0.9, 0.7].map((height, index) => ( - - - - {['M', 'T', 'W', 'T', 'F', 'S', 'S'][index]} - - - ))} - - + return last7Weeks.map((week) => { + // Normalize check-ins to 0.2-1.0 range for better visualization + const normalized = week.checkIns / maxCheckIns; + return Math.max(normalized * 0.8 + 0.2, 0.2); + }); + }; + + const weeklyBars = getWeeklyBars(); + const checkInsThisWeek = statistics?.attendance.checkInsThisWeek || 0; + const currentStreak = statistics?.attendance.currentStreak || 0; + + return ( + + + + Daily Activity + - ); + + {loading ? ( + + + + ) : ( + <> + + + + + + {checkInsThisWeek} + This Week + + + + + + + + + {calories} + Kcal + + + + + + + + + {currentStreak} + Day Streak + + + + {/* Weekly Bar Chart */} + + {weeklyBars.map((height, index) => ( + + + + {["M", "T", "W", "T", "F", "S", "S"][index % 7]} + + + ))} + + + )} + + + ); } const styles = StyleSheet.create({ - container: { - marginHorizontal: 20, - marginBottom: 20, - }, - card: { - borderRadius: 24, - padding: 20, - borderWidth: 1, - borderColor: 'rgba(255, 255, 255, 0.1)', - }, - header: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - marginBottom: 20, - }, - title: { - fontSize: 18, - fontWeight: '700', - color: '#fff', - }, - statsRow: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - marginBottom: 24, - }, - statItem: { - alignItems: 'center', - flex: 1, - }, - iconContainer: { - width: 40, - height: 40, - borderRadius: 20, - justifyContent: 'center', - alignItems: 'center', - marginBottom: 8, - }, - statValue: { - fontSize: 20, - fontWeight: '700', - color: '#fff', - marginBottom: 2, - }, - statLabel: { - fontSize: 12, - color: theme.colors.gray400, - fontWeight: '500', - }, - divider: { - width: 1, - height: 40, - backgroundColor: 'rgba(255, 255, 255, 0.1)', - }, - chartContainer: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'flex-end', - height: 80, - paddingTop: 10, - borderTopWidth: 1, - borderTopColor: 'rgba(255, 255, 255, 0.1)', - }, - barContainer: { - alignItems: 'center', - gap: 8, - }, - bar: { - width: 6, - borderRadius: 3, - opacity: 0.8, - }, - dayLabel: { - fontSize: 10, - color: theme.colors.gray500, - fontWeight: '600', - }, + container: { + marginHorizontal: 20, + marginBottom: 20, + }, + card: { + borderRadius: 24, + padding: 20, + borderWidth: 1, + borderColor: "rgba(255, 255, 255, 0.1)", + }, + header: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + marginBottom: 20, + }, + title: { + fontSize: 18, + fontWeight: "700", + color: "#fff", + }, + loadingContainer: { + paddingVertical: 40, + alignItems: "center", + justifyContent: "center", + }, + statsRow: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + marginBottom: 24, + }, + statItem: { + alignItems: "center", + flex: 1, + }, + iconContainer: { + width: 40, + height: 40, + borderRadius: 20, + justifyContent: "center", + alignItems: "center", + marginBottom: 8, + }, + statValue: { + fontSize: 20, + fontWeight: "700", + color: "#fff", + marginBottom: 2, + }, + statLabel: { + fontSize: 12, + color: theme.colors.gray400, + fontWeight: "500", + }, + divider: { + width: 1, + height: 40, + backgroundColor: "rgba(255, 255, 255, 0.1)", + }, + chartContainer: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "flex-end", + height: 80, + paddingTop: 10, + borderTopWidth: 1, + borderTopColor: "rgba(255, 255, 255, 0.1)", + }, + barContainer: { + alignItems: "center", + gap: 8, + }, + bar: { + width: 6, + borderRadius: 3, + opacity: 0.8, + }, + dayLabel: { + fontSize: 10, + color: theme.colors.gray500, + fontWeight: "600", + }, }); diff --git a/apps/mobile/src/components/AttendanceCalendar.tsx b/apps/mobile/src/components/AttendanceCalendar.tsx new file mode 100644 index 0000000..1f6f923 --- /dev/null +++ b/apps/mobile/src/components/AttendanceCalendar.tsx @@ -0,0 +1,303 @@ +import React, { useState, useEffect } from "react"; +import { View, Text, StyleSheet, TouchableOpacity } from "react-native"; +import { LinearGradient } from "expo-linear-gradient"; +import { Ionicons } from "@expo/vector-icons"; +import { theme } from "../styles/theme"; + +interface AttendanceRecord { + id: string; + checkInTime: string; + checkOutTime?: string | null; + duration?: number | null; +} + +interface AttendanceCalendarProps { + attendanceRecords: AttendanceRecord[]; +} + +export function AttendanceCalendar({ + attendanceRecords, +}: AttendanceCalendarProps) { + const [currentMonth, setCurrentMonth] = useState(new Date()); + const [calendarDays, setCalendarDays] = useState< + Array<{ date: Date | null; hasAttendance: boolean; isToday: boolean }> + >([]); + + useEffect(() => { + generateCalendar(currentMonth); + }, [currentMonth, attendanceRecords]); + + const generateCalendar = (month: Date) => { + const year = month.getFullYear(); + const monthIndex = month.getMonth(); + + // Get first day of month and number of days + const firstDay = new Date(year, monthIndex, 1); + const lastDay = new Date(year, monthIndex + 1, 0); + const daysInMonth = lastDay.getDate(); + const startingDayOfWeek = firstDay.getDay(); + + // Create attendance lookup set + const attendanceDates = new Set( + attendanceRecords.map((record) => + new Date(record.checkInTime).toDateString(), + ), + ); + + const today = new Date().toDateString(); + + // Build calendar array + const days: Array<{ + date: Date | null; + hasAttendance: boolean; + isToday: boolean; + }> = []; + + // Add empty cells for days before month starts + for (let i = 0; i < startingDayOfWeek; i++) { + days.push({ date: null, hasAttendance: false, isToday: false }); + } + + // Add days of the month + for (let day = 1; day <= daysInMonth; day++) { + const date = new Date(year, monthIndex, day); + const dateString = date.toDateString(); + days.push({ + date, + hasAttendance: attendanceDates.has(dateString), + isToday: dateString === today, + }); + } + + setCalendarDays(days); + }; + + const goToPreviousMonth = () => { + setCurrentMonth( + new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1), + ); + }; + + const goToNextMonth = () => { + setCurrentMonth( + new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1), + ); + }; + + const monthName = currentMonth.toLocaleDateString("en-US", { + month: "long", + year: "numeric", + }); + + return ( + + + {/* Header */} + + Attendance Calendar + + + {/* Month Navigation */} + + + + + {monthName} + + + + + + {/* Day Headers */} + + {["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].map((day) => ( + + {day} + + ))} + + + {/* Calendar Grid */} + + {calendarDays.map((day, index) => ( + + {day.date ? ( + + + {day.date.getDate()} + + {day.hasAttendance && } + + ) : ( + + )} + + ))} + + + {/* Legend */} + + + + Attended + + + + Today + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + paddingHorizontal: 20, + marginBottom: 20, + }, + card: { + borderRadius: theme.borderRadius["2xl"], + padding: 20, + }, + header: { + marginBottom: 16, + }, + title: { + fontSize: theme.typography.fontSize.xl, + fontWeight: theme.typography.fontWeight.bold, + color: theme.colors.gray800, + }, + monthNav: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + marginBottom: 16, + paddingHorizontal: 8, + }, + navButton: { + padding: 8, + }, + monthText: { + fontSize: theme.typography.fontSize.lg, + fontWeight: theme.typography.fontWeight.semibold, + color: theme.colors.gray700, + }, + dayHeaders: { + flexDirection: "row", + marginBottom: 8, + }, + dayHeader: { + flex: 1, + textAlign: "center", + fontSize: theme.typography.fontSize.xs, + fontWeight: theme.typography.fontWeight.semibold, + color: theme.colors.gray500, + }, + calendarGrid: { + flexDirection: "row", + flexWrap: "wrap", + }, + dayCell: { + width: `${100 / 7}%`, + aspectRatio: 1, + padding: 2, + }, + dayContent: { + flex: 1, + justifyContent: "center", + alignItems: "center", + borderRadius: theme.borderRadius.md, + position: "relative", + }, + todayContent: { + backgroundColor: theme.colors.primaryLight, + borderWidth: 1, + borderColor: theme.colors.primary, + }, + attendanceContent: { + backgroundColor: theme.colors.successLight, + }, + emptyCell: { + flex: 1, + }, + dayText: { + fontSize: theme.typography.fontSize.sm, + color: theme.colors.gray700, + fontWeight: theme.typography.fontWeight.medium, + }, + todayText: { + color: theme.colors.white, + fontWeight: theme.typography.fontWeight.bold, + }, + attendanceText: { + color: theme.colors.white, + fontWeight: theme.typography.fontWeight.bold, + }, + attendanceDot: { + position: "absolute", + bottom: 2, + width: 4, + height: 4, + borderRadius: 2, + backgroundColor: theme.colors.white, + }, + legend: { + flexDirection: "row", + justifyContent: "center", + gap: 20, + marginTop: 16, + paddingTop: 16, + borderTopWidth: 1, + borderTopColor: theme.colors.gray200, + }, + legendItem: { + flexDirection: "row", + alignItems: "center", + gap: 6, + }, + legendDotAttendance: { + width: 12, + height: 12, + borderRadius: 6, + backgroundColor: theme.colors.successLight, + }, + legendDotToday: { + width: 12, + height: 12, + borderRadius: 6, + backgroundColor: theme.colors.primaryLight, + borderWidth: 1, + borderColor: theme.colors.primary, + }, + legendText: { + fontSize: theme.typography.fontSize.xs, + color: theme.colors.gray600, + }, +}); diff --git a/apps/mobile/src/components/GoalTypeBreakdownChart.tsx b/apps/mobile/src/components/GoalTypeBreakdownChart.tsx new file mode 100644 index 0000000..22757d6 --- /dev/null +++ b/apps/mobile/src/components/GoalTypeBreakdownChart.tsx @@ -0,0 +1,100 @@ +import React from "react"; +import { View, Text, StyleSheet, Dimensions } from "react-native"; +import { PieChart } from "react-native-chart-kit"; +import { theme } from "../styles/theme"; + +interface GoalTypeData { + goalType: string; + count: number; +} + +interface GoalTypeBreakdownChartProps { + data: GoalTypeData[]; + title?: string; +} + +export function GoalTypeBreakdownChart({ + data, + title = "Goals by Type", +}: GoalTypeBreakdownChartProps) { + const screenWidth = Dimensions.get("window").width; + + // Color palette for different goal types + const colors = [ + "#3b82f6", // Blue + "#10b981", // Green + "#f59e0b", // Orange + "#8b5cf6", // Purple + "#ec4899", // Pink + "#06b6d4", // Cyan + ]; + + // Prepare chart data + const chartData = data.map((item, index) => ({ + name: item.goalType, + count: item.count, + color: colors[index % colors.length], + legendFontColor: theme.colors.gray600, + legendFontSize: 12, + })); + + const chartConfig = { + color: (opacity = 1) => `rgba(59, 130, 246, ${opacity})`, + }; + + if (data.length === 0) { + return ( + + {title} + + No goals yet + + + ); + } + + return ( + + {title} + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + backgroundColor: theme.colors.white, + borderRadius: theme.borderRadius.xl, + padding: 16, + marginBottom: 16, + ...theme.shadows.medium, + }, + title: { + fontSize: theme.typography.fontSize.lg, + fontWeight: theme.typography.fontWeight.bold, + color: theme.colors.gray700, + marginBottom: 12, + }, + chartContainer: { + alignItems: "center", + }, + emptyState: { + paddingVertical: 40, + alignItems: "center", + }, + emptyText: { + fontSize: theme.typography.fontSize.sm, + color: theme.colors.gray400, + }, +}); diff --git a/apps/mobile/src/components/WeeklyProgressChart.tsx b/apps/mobile/src/components/WeeklyProgressChart.tsx new file mode 100644 index 0000000..26e43c5 --- /dev/null +++ b/apps/mobile/src/components/WeeklyProgressChart.tsx @@ -0,0 +1,132 @@ +import React from "react"; +import { View, Text, StyleSheet, Dimensions } from "react-native"; +import { LineChart } from "react-native-chart-kit"; +import { theme } from "../styles/theme"; +import type { WeeklyTrendData } from "../api/types"; + +interface WeeklyProgressChartProps { + weeklyData: WeeklyTrendData[]; + title?: string; +} + +export function WeeklyProgressChart({ + weeklyData, + title = "Weekly Progress", +}: WeeklyProgressChartProps) { + const screenWidth = Dimensions.get("window").width; + + // Prepare chart data + const labels = weeklyData.map((week) => week.weekLabel); + const checkInsData = weeklyData.map((week) => week.checkIns); + const goalsCompletedData = weeklyData.map((week) => week.goalsCompleted); + const avgProgressData = weeklyData.map((week) => week.averageProgress); + + const chartConfig = { + backgroundColor: theme.colors.white, + backgroundGradientFrom: theme.colors.white, + backgroundGradientTo: theme.colors.white, + decimalPlaces: 0, + color: (opacity = 1) => `rgba(59, 130, 246, ${opacity})`, + labelColor: (opacity = 1) => `rgba(107, 114, 128, ${opacity})`, + style: { + borderRadius: theme.borderRadius.lg, + }, + propsForDots: { + r: "4", + strokeWidth: "2", + stroke: theme.colors.primary, + }, + }; + + const data = { + labels, + datasets: [ + { + data: checkInsData, + color: (opacity = 1) => `rgba(59, 130, 246, ${opacity})`, // Blue for check-ins + strokeWidth: 2, + }, + { + data: goalsCompletedData, + color: (opacity = 1) => `rgba(16, 185, 129, ${opacity})`, // Green for goals + strokeWidth: 2, + }, + ], + legend: ["Check-ins", "Goals Completed"], + }; + + return ( + + {title} + + + + + + + Check-ins + + + + Goals Completed + + + + ); +} + +const styles = StyleSheet.create({ + container: { + backgroundColor: theme.colors.white, + borderRadius: theme.borderRadius.xl, + padding: 16, + marginBottom: 16, + ...theme.shadows.medium, + }, + title: { + fontSize: theme.typography.fontSize.lg, + fontWeight: theme.typography.fontWeight.bold, + color: theme.colors.gray700, + marginBottom: 12, + }, + chartContainer: { + alignItems: "center", + marginBottom: 12, + }, + chart: { + marginVertical: 8, + borderRadius: theme.borderRadius.lg, + }, + legend: { + flexDirection: "row", + justifyContent: "center", + gap: 20, + paddingTop: 8, + }, + legendItem: { + flexDirection: "row", + alignItems: "center", + gap: 6, + }, + legendDot: { + width: 10, + height: 10, + borderRadius: 5, + }, + legendText: { + fontSize: theme.typography.fontSize.sm, + color: theme.colors.gray500, + }, +}); diff --git a/apps/mobile/src/components/WeeklyProgressWidget.tsx b/apps/mobile/src/components/WeeklyProgressWidget.tsx new file mode 100644 index 0000000..7ee9ea7 --- /dev/null +++ b/apps/mobile/src/components/WeeklyProgressWidget.tsx @@ -0,0 +1,205 @@ +import React, { useState, useEffect } from "react"; +import { View, Text, StyleSheet, ActivityIndicator } from "react-native"; +import { LinearGradient } from "expo-linear-gradient"; +import { Ionicons } from "@expo/vector-icons"; +import { theme } from "../styles/theme"; +import { useUser } from "@clerk/clerk-expo"; +import { getUserStatistics } from "../api/statistics"; +import type { WeeklyTrendData } from "../api/types"; + +export function WeeklyProgressWidget() { + const { user } = useUser(); + const [weeklyData, setWeeklyData] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const loadWeeklyData = async () => { + if (!user?.id) return; + + try { + const stats = await getUserStatistics(user.id); + // Get last 4 weeks for compact display + const last4Weeks = stats.weeklyTrend.slice(-4); + setWeeklyData(last4Weeks); + } catch (error) { + console.error("Failed to load weekly data:", error); + } finally { + setLoading(false); + } + }; + + loadWeeklyData(); + }, [user?.id]); + + if (loading) { + return ( + + + + ); + } + + if (weeklyData.length === 0) { + return null; + } + + // Calculate totals from last 4 weeks + const totalCheckIns = weeklyData.reduce( + (sum, week) => sum + week.checkIns, + 0, + ); + const totalGoals = weeklyData.reduce( + (sum, week) => sum + week.goalsCompleted, + 0, + ); + const avgProgress = + weeklyData.reduce((sum, week) => sum + week.averageProgress, 0) / + weeklyData.length; + + // Get max value for scaling bars + const maxCheckIns = Math.max(...weeklyData.map((w) => w.checkIns), 1); + + return ( + + + + + Weekly Progress + Last 4 weeks + + + + + + + + + {totalCheckIns} + Check-ins + + + + {totalGoals} + Goals Met + + + + {Math.round(avgProgress)}% + Avg Progress + + + + + {weeklyData.map((week, index) => { + const barHeight = (week.checkIns / maxCheckIns) * 60; + return ( + + + + + {week.weekLabel} + + ); + })} + + + + ); +} + +const styles = StyleSheet.create({ + container: { + paddingHorizontal: 20, + marginBottom: 16, + }, + card: { + borderRadius: theme.borderRadius["2xl"], + padding: 20, + ...theme.shadows.medium, + }, + header: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "flex-start", + marginBottom: 20, + }, + title: { + fontSize: theme.typography.fontSize.xl, + fontWeight: theme.typography.fontWeight.bold, + color: theme.colors.white, + marginBottom: 4, + }, + subtitle: { + fontSize: theme.typography.fontSize.sm, + color: "rgba(255, 255, 255, 0.8)", + }, + iconContainer: { + backgroundColor: "rgba(255, 255, 255, 0.2)", + width: 48, + height: 48, + borderRadius: 24, + justifyContent: "center", + alignItems: "center", + }, + statsRow: { + flexDirection: "row", + justifyContent: "space-around", + marginBottom: 20, + backgroundColor: "rgba(255, 255, 255, 0.15)", + borderRadius: theme.borderRadius.xl, + padding: 16, + }, + statItem: { + alignItems: "center", + flex: 1, + }, + statValue: { + fontSize: theme.typography.fontSize["2xl"], + fontWeight: theme.typography.fontWeight.bold, + color: theme.colors.white, + marginBottom: 4, + }, + statLabel: { + fontSize: theme.typography.fontSize.xs, + color: "rgba(255, 255, 255, 0.7)", + textAlign: "center", + }, + statDivider: { + width: 1, + backgroundColor: "rgba(255, 255, 255, 0.2)", + marginHorizontal: 8, + }, + chartContainer: { + flexDirection: "row", + justifyContent: "space-around", + alignItems: "flex-end", + height: 80, + }, + barContainer: { + alignItems: "center", + flex: 1, + }, + barWrapper: { + height: 60, + justifyContent: "flex-end", + marginBottom: 6, + }, + bar: { + width: 24, + backgroundColor: "rgba(255, 255, 255, 0.9)", + borderRadius: 4, + minHeight: 4, + }, + barLabel: { + fontSize: 10, + color: "rgba(255, 255, 255, 0.7)", + fontWeight: "500", + }, +}); diff --git a/apps/mobile/src/config/api.ts b/apps/mobile/src/config/api.ts index 7f43c9c..08c3d3a 100644 --- a/apps/mobile/src/config/api.ts +++ b/apps/mobile/src/config/api.ts @@ -32,7 +32,10 @@ export const API_ENDPOINTS = { FITNESS: "/api/fitness-profile", }, CLIENTS: "/api/clients", - USERS: "/api/users", + USERS: { + LIST: "/api/users", + STATISTICS: "/api/users/statistics", + }, GYMS: "/api/gyms", ATTENDANCE: { CHECK_IN: "/api/attendance/check-in",