diff --git a/apps/admin/src/app/api/users/statistics/route.ts b/apps/admin/src/app/api/users/statistics/route.ts index 43a7eb6..208fbb2 100644 --- a/apps/admin/src/app/api/users/statistics/route.ts +++ b/apps/admin/src/app/api/users/statistics/route.ts @@ -44,13 +44,39 @@ interface DashboardStatistics { // GET - Fetch dashboard statistics for authenticated user export async function GET(request: NextRequest) { try { - const { userId } = await auth(); + // Log the request details for debugging + log.debug("Statistics endpoint called", { + url: request.url, + hasAuthHeader: !!request.headers.get("authorization"), + authHeaderPreview: request.headers.get("authorization")?.substring(0, 30), + }); - if (!userId) { + const { userId: authenticatedUserId } = await auth(); + + log.debug("Auth result", { + authenticatedUserId, + isAuthenticated: !!authenticatedUserId, + }); + + if (!authenticatedUserId) { + log.error("Statistics GET - authentication failed", { + hasAuthHeader: !!request.headers.get("authorization"), + url: request.url, + }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - log.debug("Fetching statistics for user", { userId }); + // Get target user ID from query params, default to authenticated user + const { searchParams } = new URL(request.url); + const userId = searchParams.get("userId") || authenticatedUserId; + + // Users can only access their own statistics unless they're staff + // (we could add role checking here if needed) + + log.debug("Fetching statistics for user", { + authenticatedUserId, + targetUserId: userId, + }); // Get goal statistics const goals = db @@ -284,11 +310,39 @@ export async function GET(request: NextRequest) { totalCheckIns, }); - return NextResponse.json({ statistics }); + return NextResponse.json({ + success: true, + data: { + statistics: { + userId, + ...statistics, + }, + }, + meta: { + timestamp: new Date().toISOString(), + requestId: `req_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`, + }, + }); + + return NextResponse.json({ + success: true, + data: { statistics }, + meta: { + timestamp: new Date().toISOString(), + requestId: `req_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`, + }, + }); } catch (error) { log.error("Failed to fetch statistics", error); return NextResponse.json( - { error: "Failed to fetch statistics" }, + { + success: false, + error: { message: "Failed to fetch statistics" }, + meta: { + timestamp: new Date().toISOString(), + requestId: `req_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`, + }, + }, { status: 500 }, ); } diff --git a/apps/mobile/src/api/recommendations.ts b/apps/mobile/src/api/recommendations.ts index 206f765..79e0de3 100644 --- a/apps/mobile/src/api/recommendations.ts +++ b/apps/mobile/src/api/recommendations.ts @@ -1,5 +1,4 @@ -import { apiClient } from "./client"; -import { API_ENDPOINTS } from "../config/api"; +import { API_BASE_URL, API_ENDPOINTS } from "../config/api"; export interface Recommendation { id: string; @@ -26,51 +25,124 @@ export interface GenerateRecommendationRequest { * Get recommendations for a user * * @param userId - Clerk user ID + * @param token - Auth token * @returns Array of recommendations */ export async function getRecommendations( userId: string, + token: string | null, ): Promise { - const response = await apiClient.get( - API_ENDPOINTS.RECOMMENDATIONS, - { - params: { userId }, - }, + const headers: any = { + "Content-Type": "application/json", + }; + + if (token) { + headers["Authorization"] = `Bearer ${token}`; + } + + const response = await fetch( + `${API_BASE_URL}${API_ENDPOINTS.RECOMMENDATIONS}?userId=${userId}`, + { headers }, ); - return response.data; + if (!response.ok) { + throw new Error(`Failed to fetch recommendations: ${response.status}`); + } + + const result = await response.json(); + + // Handle standardized API response format + // API returns: { success: true, data: [...], meta: {...} } + if (result.success && result.data) { + return Array.isArray(result.data) ? result.data : []; + } + + // Fallback for legacy format (direct array) + return Array.isArray(result) ? result : []; } /** * Generate a new AI recommendation for a user * * @param data - Generation request data + * @param token - Auth token * @returns The generated recommendation */ export async function generateRecommendation( data: GenerateRecommendationRequest, + token: string | null, ): Promise { - const response = await apiClient.post( - `${API_ENDPOINTS.RECOMMENDATIONS}/generate`, - data, + const headers: any = { + "Content-Type": "application/json", + }; + + if (token) { + headers["Authorization"] = `Bearer ${token}`; + } + + const response = await fetch( + `${API_BASE_URL}${API_ENDPOINTS.RECOMMENDATIONS}/generate`, + { + method: "POST", + headers, + body: JSON.stringify(data), + }, ); - return response.data; + if (!response.ok) { + throw new Error(`Failed to generate recommendation: ${response.status}`); + } + + const result = await response.json(); + + // Handle standardized API response format + if (result.success && result.data) { + return result.data; + } + + // Fallback for legacy format + return result; } /** * Approve a recommendation (admin/trainer only) * * @param recommendationId - Recommendation ID + * @param token - Auth token * @returns The approved recommendation */ export async function approveRecommendation( recommendationId: string, + token: string | null, ): Promise { - const response = await apiClient.post( - `${API_ENDPOINTS.RECOMMENDATIONS}/approve`, - { id: recommendationId }, + const headers: any = { + "Content-Type": "application/json", + }; + + if (token) { + headers["Authorization"] = `Bearer ${token}`; + } + + const response = await fetch( + `${API_BASE_URL}${API_ENDPOINTS.RECOMMENDATIONS}/approve`, + { + method: "POST", + headers, + body: JSON.stringify({ id: recommendationId }), + }, ); - return response.data; + if (!response.ok) { + throw new Error(`Failed to approve recommendation: ${response.status}`); + } + + const result = await response.json(); + + // Handle standardized API response format + if (result.success && result.data) { + return result.data; + } + + // Fallback for legacy format + return result; } diff --git a/apps/mobile/src/api/statistics.ts b/apps/mobile/src/api/statistics.ts index ef81589..fc0e499 100644 --- a/apps/mobile/src/api/statistics.ts +++ b/apps/mobile/src/api/statistics.ts @@ -6,17 +6,21 @@ import type { UserStatisticsResponse } from "./types"; * Fetch user statistics including goals, attendance, and weekly trends * * @param userId - Clerk user ID + * @param token - Clerk authentication token * @returns User statistics data */ export async function getUserStatistics( userId: string, + token?: string | null, ): Promise { - const response = await apiClient.get( - API_ENDPOINTS.USERS.STATISTICS, - { - params: { userId }, - }, - ); + const response = await apiClient.get<{ + success: boolean; + data: { statistics: UserStatisticsResponse }; + }>(API_ENDPOINTS.USERS.STATISTICS, { + params: { userId }, + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); - return response.data; + // Extract statistics from standardized API response format + return response.data.data.statistics; } diff --git a/apps/mobile/src/app/(tabs)/goals.tsx b/apps/mobile/src/app/(tabs)/goals.tsx index 5a1dbf5..6776775 100644 --- a/apps/mobile/src/app/(tabs)/goals.tsx +++ b/apps/mobile/src/app/(tabs)/goals.tsx @@ -16,70 +16,42 @@ import { GoalProgressCard } from "../../components/GoalProgressCard"; import { GoalCreationModal } from "../../components/GoalCreationModal"; import { WeeklyProgressChart } from "../../components/WeeklyProgressChart"; import { GoalTypeBreakdownChart } from "../../components/GoalTypeBreakdownChart"; -import { useUser, useAuth } from "@clerk/clerk-expo"; -import { - fitnessGoalsService, - type FitnessGoal, - type CreateGoalData, -} from "../../services/fitnessGoals"; -import { getUserStatistics } from "../../api/statistics"; -import type { UserStatisticsResponse } from "../../api/types"; +import { useUser } from "@clerk/clerk-expo"; +import type { FitnessGoal, CreateGoalData } from "../../services/fitnessGoals"; +import { useStatistics } from "../../contexts/StatisticsContext"; +import { useFitnessGoals } from "../../contexts/FitnessGoalsContext"; +import { useRecommendations } from "../../contexts/RecommendationsContext"; import { useFocusEffect } from "expo-router"; import * as SecureStore from "expo-secure-store"; import log from "../../utils/logger"; export default function GoalsScreen() { const { user } = useUser(); - const { getToken } = useAuth(); - const [goals, setGoals] = useState([]); - const [statistics, setStatistics] = useState( - null, - ); + const { + statistics, + refetchStatistics, + clearCache: clearStatsCache, + } = useStatistics(); + const { + goals, + loading, + refetchGoals, + createGoal, + completeGoal, + deleteGoal, + clearCache: clearGoalsCache, + } = useFitnessGoals(); + const { clearCache: clearRecommendationsCache } = useRecommendations(); 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 () => { - if (!user?.id) return; - try { - const token = await getToken(); - log.debug("Token obtained", { - hasToken: !!token, - tokenPreview: token ? token.substring(0, 20) + "..." : "No", - userId: user.id, - }); - - // Decode and log token details for debugging - if (token) { - try { - const parts = token.split("."); - if (parts.length === 3) { - const payload = JSON.parse(atob(parts[1])); - log.debug("Token details", { - issuer: payload.iss, - kid: JSON.parse(atob(parts[0])).kid, - }); - } - } catch (e) { - log.warn("Could not decode token"); - } - } - - 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); - } - }, [user?.id, getToken]); + const loadData = useCallback(async () => { + // Load goals and statistics (both cached) + await refetchGoals(); + await refetchStatistics(); + }, [refetchGoals, refetchStatistics]); const clearClerkCache = async () => { Alert.alert( @@ -110,6 +82,11 @@ export default function GoalsScreen() { } } + // Clear all caches + clearStatsCache(); + clearGoalsCache(); + clearRecommendationsCache(); + Alert.alert( "Success", "Cache cleared! Please sign out and sign back in.", @@ -126,37 +103,31 @@ export default function GoalsScreen() { useFocusEffect( useCallback(() => { - loadGoals(); - }, [loadGoals]), + loadData(); + }, [loadData]), ); const onRefresh = async () => { setRefreshing(true); - await loadGoals(); + await loadData(); setRefreshing(false); }; const handleCreateGoal = async (newGoal: CreateGoalData) => { - const token = await getToken(); - await fitnessGoalsService.createGoal(newGoal, token); - await loadGoals(); + await createGoal(newGoal); setIsModalVisible(false); }; const handleCompleteGoal = async (goal: FitnessGoal) => { - const token = await getToken(); - await fitnessGoalsService.completeGoal(goal.id, token); - await loadGoals(); + await completeGoal(goal.id); }; const handleDeleteGoal = async (goalId: string) => { - const token = await getToken(); - await fitnessGoalsService.deleteGoal(goalId, token); - await loadGoals(); + await deleteGoal(goalId); }; - const activeGoals = goals.filter((g) => g.status === "active"); - const completedGoals = goals.filter((g) => g.status === "completed"); + const activeGoals = goals?.filter((g) => g.status === "active") || []; + const completedGoals = goals?.filter((g) => g.status === "completed") || []; return ( @@ -188,7 +159,7 @@ export default function GoalsScreen() { {/* Stats Summary */} - {goals.length > 0 && ( + {goals && goals.length > 0 && ( {activeGoals.length} diff --git a/apps/mobile/src/app/(tabs)/recommendations.tsx b/apps/mobile/src/app/(tabs)/recommendations.tsx index d3906f5..9cd0cdd 100644 --- a/apps/mobile/src/app/(tabs)/recommendations.tsx +++ b/apps/mobile/src/app/(tabs)/recommendations.tsx @@ -14,46 +14,35 @@ 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 { useRecommendations } from "../../contexts/RecommendationsContext"; +import type { Recommendation } from "../../api/recommendations"; import log from "../../utils/logger"; export default function RecommendationsScreen() { const { user } = useUser(); - const [recommendations, setRecommendations] = useState([]); - const [loading, setLoading] = useState(true); + const { + recommendations: allRecommendations, + loading, + refetchRecommendations, + generateNewRecommendation, + } = useRecommendations(); const [generating, setGenerating] = useState(false); const [refreshing, setRefreshing] = useState(false); - const loadRecommendations = useCallback(async () => { - if (!user?.id) return; - - try { - 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); - } - }, [user?.id]); + // Filter to show only approved recommendations for regular users + const recommendations = allRecommendations.filter( + (rec) => rec.status === "approved", + ); useFocusEffect( useCallback(() => { - loadRecommendations(); - }, [loadRecommendations]), + refetchRecommendations(); + }, [refetchRecommendations]), ); const onRefresh = async () => { setRefreshing(true); - await loadRecommendations(); + await refetchRecommendations(); setRefreshing(false); }; @@ -70,7 +59,7 @@ export default function RecommendationsScreen() { onPress: async () => { try { setGenerating(true); - await generateRecommendation({ + await generateNewRecommendation({ userId: user.id, modelProvider: "openai", useExternalModel: true, @@ -79,7 +68,7 @@ export default function RecommendationsScreen() { "Success", "AI recommendation generated! It will appear here once approved by your trainer.", ); - await loadRecommendations(); + await refetchRecommendations(); } catch (error) { log.error("Failed to generate recommendation", error); Alert.alert( diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 24614b4..40e5c1f 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -4,6 +4,9 @@ import * as SecureStore from "expo-secure-store"; import { View, Text } from "react-native"; import { useEffect, useState } from "react"; import { validateEnv } from "../utils/env"; +import { StatisticsProvider } from "../contexts/StatisticsContext"; +import { FitnessGoalsProvider } from "../contexts/FitnessGoalsContext"; +import { RecommendationsProvider } from "../contexts/RecommendationsContext"; import log from "../utils/logger"; // Validate environment variables on app startup @@ -150,11 +153,17 @@ export default function RootLayout() { return ( - - - - - + + + + + + + + + + + ); diff --git a/apps/mobile/src/components/ActivityWidget.tsx b/apps/mobile/src/components/ActivityWidget.tsx index 30bc384..5b18bfd 100644 --- a/apps/mobile/src/components/ActivityWidget.tsx +++ b/apps/mobile/src/components/ActivityWidget.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from "react"; +import React, { useEffect } from "react"; import { View, Text, @@ -8,10 +8,8 @@ import { } 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"; +import { useStatistics } from "../contexts/StatisticsContext"; const { width } = Dimensions.get("window"); @@ -26,28 +24,11 @@ export function ActivityWidget({ calories, duration = 0, }: ActivityWidgetProps) { - const { user } = useUser(); - const [statistics, setStatistics] = useState( - null, - ); - const [loading, setLoading] = useState(true); + const { statistics, loading, refetchStatistics } = useStatistics(); 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); - } - }; - - loadStatistics(); - }, [user?.id]); + refetchStatistics(); + }, [refetchStatistics]); // Calculate weekly activity bars from weekly trend data const getWeeklyBars = () => { diff --git a/apps/mobile/src/components/WeeklyProgressWidget.tsx b/apps/mobile/src/components/WeeklyProgressWidget.tsx index 7ee9ea7..f2fefbe 100644 --- a/apps/mobile/src/components/WeeklyProgressWidget.tsx +++ b/apps/mobile/src/components/WeeklyProgressWidget.tsx @@ -3,33 +3,24 @@ 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 { useStatistics } from "../contexts/StatisticsContext"; import type { WeeklyTrendData } from "../api/types"; export function WeeklyProgressWidget() { - const { user } = useUser(); + const { statistics, loading, refetchStatistics } = useStatistics(); const [weeklyData, setWeeklyData] = useState([]); - const [loading, setLoading] = useState(true); useEffect(() => { - const loadWeeklyData = async () => { - if (!user?.id) return; + refetchStatistics(); + }, [refetchStatistics]); - 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]); + useEffect(() => { + if (statistics?.weeklyTrend) { + // Get last 4 weeks for compact display + const last4Weeks = statistics.weeklyTrend.slice(-4); + setWeeklyData(last4Weeks); + } + }, [statistics]); if (loading) { return ( diff --git a/apps/mobile/src/contexts/FitnessGoalsContext.tsx b/apps/mobile/src/contexts/FitnessGoalsContext.tsx new file mode 100644 index 0000000..d4150c1 --- /dev/null +++ b/apps/mobile/src/contexts/FitnessGoalsContext.tsx @@ -0,0 +1,204 @@ +import React, { + createContext, + useContext, + useState, + useCallback, + useRef, +} from "react"; +import { useUser, useAuth } from "@clerk/clerk-expo"; +import { + fitnessGoalsService, + type FitnessGoal, + type CreateGoalData, +} from "../services/fitnessGoals"; +import log from "../utils/logger"; + +interface FitnessGoalsContextValue { + goals: FitnessGoal[]; + loading: boolean; + error: Error | null; + refetchGoals: () => Promise; + createGoal: (goalData: CreateGoalData) => Promise; + updateGoal: ( + id: string, + updates: Partial, + ) => Promise; + completeGoal: (id: string) => Promise; + deleteGoal: (id: string) => Promise; + clearCache: () => void; +} + +const FitnessGoalsContext = createContext( + undefined, +); + +export function FitnessGoalsProvider({ + children, +}: { + children: React.ReactNode; +}) { + const { user } = useUser(); + const { getToken } = useAuth(); + const [goals, setGoals] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [lastFetchTime, setLastFetchTime] = useState(0); + const fetchInProgress = useRef(false); + + // Cache goals for 30 seconds to avoid duplicate calls + const CACHE_DURATION = 30000; // 30 seconds + + const refetchGoals = useCallback(async () => { + if (!user?.id) return; + + // Prevent concurrent fetches + if (fetchInProgress.current) { + log.debug("Fetch already in progress, skipping duplicate call"); + return; + } + + // Check if we have recent cached data + const now = Date.now(); + if (goals.length > 0 && now - lastFetchTime < CACHE_DURATION) { + log.debug("Using cached fitness goals", { + count: goals.length, + age: now - lastFetchTime, + cacheRemaining: CACHE_DURATION - (now - lastFetchTime), + }); + return; + } + + try { + fetchInProgress.current = true; + setLoading(true); + setError(null); + log.debug("Fetching fresh fitness goals", { userId: user.id }); + + const token = await getToken(); + const loadedGoals = await fitnessGoalsService.getGoals(user.id, token); + + setGoals(loadedGoals); + setLastFetchTime(now); + log.debug("Fitness goals fetched and cached", { + count: loadedGoals.length, + }); + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + log.error("Failed to fetch fitness goals", error); + setError(error); + } finally { + setLoading(false); + fetchInProgress.current = false; + } + }, [user?.id, getToken, goals.length, lastFetchTime]); + + const createGoal = useCallback( + async (goalData: CreateGoalData): Promise => { + if (!user?.id) throw new Error("User not authenticated"); + + const token = await getToken(); + const newGoal = await fitnessGoalsService.createGoal(goalData, token); + + // Optimistically update cache + setGoals((prev) => [...prev, newGoal]); + setLastFetchTime(Date.now()); + log.debug("Goal created and added to cache", { goalId: newGoal.id }); + + return newGoal; + }, + [user?.id, getToken], + ); + + const updateGoal = useCallback( + async (id: string, updates: Partial): Promise => { + if (!user?.id) throw new Error("User not authenticated"); + + const token = await getToken(); + const updatedGoal = await fitnessGoalsService.updateGoal( + id, + updates, + token, + ); + + // Optimistically update cache + setGoals((prev) => + prev.map((goal) => (goal.id === id ? updatedGoal : goal)), + ); + setLastFetchTime(Date.now()); + log.debug("Goal updated in cache", { goalId: id }); + + return updatedGoal; + }, + [user?.id, getToken], + ); + + const completeGoal = useCallback( + async (id: string): Promise => { + if (!user?.id) throw new Error("User not authenticated"); + + const token = await getToken(); + const completedGoal = await fitnessGoalsService.completeGoal(id, token); + + // Optimistically update cache + setGoals((prev) => + prev.map((goal) => (goal.id === id ? completedGoal : goal)), + ); + setLastFetchTime(Date.now()); + log.debug("Goal completed in cache", { goalId: id }); + + return completedGoal; + }, + [user?.id, getToken], + ); + + const deleteGoal = useCallback( + async (id: string): Promise => { + if (!user?.id) throw new Error("User not authenticated"); + + const token = await getToken(); + await fitnessGoalsService.deleteGoal(id, token); + + // Optimistically update cache + setGoals((prev) => prev.filter((goal) => goal.id !== id)); + setLastFetchTime(Date.now()); + log.debug("Goal deleted from cache", { goalId: id }); + }, + [user?.id, getToken], + ); + + const clearCache = useCallback(() => { + setGoals([]); + setLastFetchTime(0); + setError(null); + fetchInProgress.current = false; + log.debug("Fitness goals cache cleared"); + }, []); + + return ( + + {children} + + ); +} + +export function useFitnessGoals() { + const context = useContext(FitnessGoalsContext); + if (context === undefined) { + throw new Error( + "useFitnessGoals must be used within a FitnessGoalsProvider", + ); + } + return context; +} diff --git a/apps/mobile/src/contexts/RecommendationsContext.tsx b/apps/mobile/src/contexts/RecommendationsContext.tsx new file mode 100644 index 0000000..a2c81d5 --- /dev/null +++ b/apps/mobile/src/contexts/RecommendationsContext.tsx @@ -0,0 +1,141 @@ +import React, { + createContext, + useContext, + useState, + useCallback, + useRef, +} from "react"; +import { useUser, useAuth } from "@clerk/clerk-expo"; +import { + getRecommendations, + generateRecommendation, + type Recommendation, + type GenerateRecommendationRequest, +} from "../api/recommendations"; +import log from "../utils/logger"; + +interface RecommendationsContextValue { + recommendations: Recommendation[]; + loading: boolean; + error: Error | null; + refetchRecommendations: () => Promise; + generateNewRecommendation: ( + data: GenerateRecommendationRequest, + ) => Promise; + clearCache: () => void; +} + +const RecommendationsContext = createContext< + RecommendationsContextValue | undefined +>(undefined); + +export function RecommendationsProvider({ + children, +}: { + children: React.ReactNode; +}) { + const { user } = useUser(); + const { getToken } = useAuth(); + const [recommendations, setRecommendations] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [lastFetchTime, setLastFetchTime] = useState(0); + const fetchInProgress = useRef(false); + + // Cache recommendations for 30 seconds + const CACHE_DURATION = 30000; // 30 seconds + + const refetchRecommendations = useCallback(async () => { + if (!user?.id) return; + + // Prevent concurrent fetches + if (fetchInProgress.current) { + log.debug("Recommendations fetch already in progress, skipping"); + return; + } + + // Check if we have recent cached data + const now = Date.now(); + if (recommendations.length > 0 && now - lastFetchTime < CACHE_DURATION) { + log.debug("Using cached recommendations", { + count: recommendations.length, + age: now - lastFetchTime, + cacheRemaining: CACHE_DURATION - (now - lastFetchTime), + }); + return; + } + + try { + fetchInProgress.current = true; + setLoading(true); + setError(null); + log.debug("Fetching fresh recommendations", { userId: user.id }); + + const token = await getToken(); + const data = await getRecommendations(user.id, token); + + setRecommendations(data); + setLastFetchTime(now); + log.debug("Recommendations fetched and cached", { count: data.length }); + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + log.error("Failed to fetch recommendations", error); + setError(error); + } finally { + setLoading(false); + fetchInProgress.current = false; + } + }, [user?.id, getToken, recommendations.length, lastFetchTime]); + + const generateNewRecommendation = useCallback( + async (data: GenerateRecommendationRequest): Promise => { + if (!user?.id) throw new Error("User not authenticated"); + + const token = await getToken(); + const newRecommendation = await generateRecommendation(data, token); + + // Optimistically update cache + setRecommendations((prev) => [...prev, newRecommendation]); + setLastFetchTime(Date.now()); + log.debug("Recommendation generated and added to cache", { + recommendationId: newRecommendation.id, + }); + + return newRecommendation; + }, + [user?.id, getToken], + ); + + const clearCache = useCallback(() => { + setRecommendations([]); + setLastFetchTime(0); + setError(null); + fetchInProgress.current = false; + log.debug("Recommendations cache cleared"); + }, []); + + return ( + + {children} + + ); +} + +export function useRecommendations() { + const context = useContext(RecommendationsContext); + if (context === undefined) { + throw new Error( + "useRecommendations must be used within a RecommendationsProvider", + ); + } + return context; +} diff --git a/apps/mobile/src/contexts/StatisticsContext.tsx b/apps/mobile/src/contexts/StatisticsContext.tsx new file mode 100644 index 0000000..68f7567 --- /dev/null +++ b/apps/mobile/src/contexts/StatisticsContext.tsx @@ -0,0 +1,97 @@ +import React, { createContext, useContext, useState, useCallback } from "react"; +import { useUser, useAuth } from "@clerk/clerk-expo"; +import { getUserStatistics } from "../api/statistics"; +import type { UserStatisticsResponse } from "../api/types"; +import log from "../utils/logger"; + +interface StatisticsContextValue { + statistics: UserStatisticsResponse | null; + loading: boolean; + error: Error | null; + refetchStatistics: () => Promise; + clearCache: () => void; +} + +const StatisticsContext = createContext( + undefined, +); + +export function StatisticsProvider({ + children, +}: { + children: React.ReactNode; +}) { + const { user } = useUser(); + const { getToken } = useAuth(); + const [statistics, setStatistics] = useState( + null, + ); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [lastFetchTime, setLastFetchTime] = useState(0); + + // Cache statistics for 30 seconds to avoid duplicate calls + const CACHE_DURATION = 30000; // 30 seconds + + const refetchStatistics = useCallback(async () => { + if (!user?.id) return; + + // Check if we have recent cached data + const now = Date.now(); + if (statistics && now - lastFetchTime < CACHE_DURATION) { + log.debug("Using cached statistics", { + age: now - lastFetchTime, + cacheRemaining: CACHE_DURATION - (now - lastFetchTime), + }); + return; + } + + try { + setLoading(true); + setError(null); + log.debug("Fetching fresh statistics", { userId: user.id }); + + const token = await getToken(); + const stats = await getUserStatistics(user.id, token); + + setStatistics(stats); + setLastFetchTime(now); + log.debug("Statistics fetched and cached", { stats }); + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + log.error("Failed to fetch statistics", error); + setError(error); + } finally { + setLoading(false); + } + }, [user?.id, getToken, statistics, lastFetchTime]); + + const clearCache = useCallback(() => { + setStatistics(null); + setLastFetchTime(0); + setError(null); + log.debug("Statistics cache cleared"); + }, []); + + return ( + + {children} + + ); +} + +export function useStatistics() { + const context = useContext(StatisticsContext); + if (context === undefined) { + throw new Error("useStatistics must be used within a StatisticsProvider"); + } + return context; +} diff --git a/apps/mobile/src/services/fitnessGoals.ts b/apps/mobile/src/services/fitnessGoals.ts index 7e08f04..15c4867 100644 --- a/apps/mobile/src/services/fitnessGoals.ts +++ b/apps/mobile/src/services/fitnessGoals.ts @@ -72,7 +72,16 @@ export class FitnessGoalsService { throw new Error(`Failed to fetch goals: ${response.status}`); } - return await response.json(); + const result = await response.json(); + + // Handle standardized API response format + // API returns: { success: true, data: [...], meta: {...} } + if (result.success && result.data) { + return Array.isArray(result.data) ? result.data : []; + } + + // Fallback for legacy format (direct array) + return Array.isArray(result) ? result : []; } catch (error) { log.error("Failed to fetch fitness goals", error); throw error; @@ -99,7 +108,15 @@ export class FitnessGoalsService { throw new Error(error.error || "Failed to create goal"); } - return await response.json(); + const result = await response.json(); + + // Handle standardized API response format + if (result.success && result.data) { + return result.data; + } + + // Fallback for legacy format + return result; } catch (error) { log.error("Failed to create fitness goal", error); throw error; @@ -126,7 +143,15 @@ export class FitnessGoalsService { throw new Error("Failed to update goal"); } - return await response.json(); + const result = await response.json(); + + // Handle standardized API response format + if (result.success && result.data) { + return result.data; + } + + // Fallback for legacy format + return result; } catch (error) { log.error("Failed to update fitness goal", error); throw error; @@ -156,7 +181,16 @@ export class FitnessGoalsService { throw new Error("Failed to complete goal"); } - return await response.json(); + const result = await response.json(); + + // Note: Complete endpoint returns direct object (legacy format) + // Handle standardized API response format (if migrated) + if (result.success && result.data) { + return result.data; + } + + // Fallback for legacy format (current implementation) + return result; } catch (error) { log.error("Failed to complete fitness goal", error); throw error; @@ -177,6 +211,9 @@ export class FitnessGoalsService { if (!response.ok) { throw new Error("Failed to delete goal"); } + + // DELETE endpoint returns: { success: true, data: { deleted: true }, meta: {...} } + // No need to parse the result for void return type } catch (error) { log.error("Failed to delete fitness goal", error); throw error;