api call optimized

This commit is contained in:
echo 2026-03-11 03:43:34 +01:00
parent 97436c6823
commit 612259f020
12 changed files with 727 additions and 177 deletions

View File

@ -44,13 +44,39 @@ interface DashboardStatistics {
// GET - Fetch dashboard statistics for authenticated user // GET - Fetch dashboard statistics for authenticated user
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { 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 }); 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 // Get goal statistics
const goals = db const goals = db
@ -284,11 +310,39 @@ export async function GET(request: NextRequest) {
totalCheckIns, 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) { } catch (error) {
log.error("Failed to fetch statistics", error); log.error("Failed to fetch statistics", error);
return NextResponse.json( 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 }, { status: 500 },
); );
} }

View File

@ -1,5 +1,4 @@
import { apiClient } from "./client"; import { API_BASE_URL, API_ENDPOINTS } from "../config/api";
import { API_ENDPOINTS } from "../config/api";
export interface Recommendation { export interface Recommendation {
id: string; id: string;
@ -26,51 +25,124 @@ export interface GenerateRecommendationRequest {
* Get recommendations for a user * Get recommendations for a user
* *
* @param userId - Clerk user ID * @param userId - Clerk user ID
* @param token - Auth token
* @returns Array of recommendations * @returns Array of recommendations
*/ */
export async function getRecommendations( export async function getRecommendations(
userId: string, userId: string,
token: string | null,
): Promise<Recommendation[]> { ): Promise<Recommendation[]> {
const response = await apiClient.get<Recommendation[]>( const headers: any = {
API_ENDPOINTS.RECOMMENDATIONS, "Content-Type": "application/json",
{ };
params: { userId },
}, 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 * Generate a new AI recommendation for a user
* *
* @param data - Generation request data * @param data - Generation request data
* @param token - Auth token
* @returns The generated recommendation * @returns The generated recommendation
*/ */
export async function generateRecommendation( export async function generateRecommendation(
data: GenerateRecommendationRequest, data: GenerateRecommendationRequest,
token: string | null,
): Promise<Recommendation> { ): Promise<Recommendation> {
const response = await apiClient.post<Recommendation>( const headers: any = {
`${API_ENDPOINTS.RECOMMENDATIONS}/generate`, "Content-Type": "application/json",
data, };
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) * Approve a recommendation (admin/trainer only)
* *
* @param recommendationId - Recommendation ID * @param recommendationId - Recommendation ID
* @param token - Auth token
* @returns The approved recommendation * @returns The approved recommendation
*/ */
export async function approveRecommendation( export async function approveRecommendation(
recommendationId: string, recommendationId: string,
token: string | null,
): Promise<Recommendation> { ): Promise<Recommendation> {
const response = await apiClient.post<Recommendation>( const headers: any = {
`${API_ENDPOINTS.RECOMMENDATIONS}/approve`, "Content-Type": "application/json",
{ id: recommendationId }, };
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;
} }

View File

@ -6,17 +6,21 @@ import type { UserStatisticsResponse } from "./types";
* Fetch user statistics including goals, attendance, and weekly trends * Fetch user statistics including goals, attendance, and weekly trends
* *
* @param userId - Clerk user ID * @param userId - Clerk user ID
* @param token - Clerk authentication token
* @returns User statistics data * @returns User statistics data
*/ */
export async function getUserStatistics( export async function getUserStatistics(
userId: string, userId: string,
token?: string | null,
): Promise<UserStatisticsResponse> { ): Promise<UserStatisticsResponse> {
const response = await apiClient.get<UserStatisticsResponse>( const response = await apiClient.get<{
API_ENDPOINTS.USERS.STATISTICS, success: boolean;
{ data: { statistics: UserStatisticsResponse };
}>(API_ENDPOINTS.USERS.STATISTICS, {
params: { userId }, params: { userId },
}, headers: token ? { Authorization: `Bearer ${token}` } : {},
); });
return response.data; // Extract statistics from standardized API response format
return response.data.data.statistics;
} }

View File

@ -16,70 +16,42 @@ import { GoalProgressCard } from "../../components/GoalProgressCard";
import { GoalCreationModal } from "../../components/GoalCreationModal"; import { GoalCreationModal } from "../../components/GoalCreationModal";
import { WeeklyProgressChart } from "../../components/WeeklyProgressChart"; import { WeeklyProgressChart } from "../../components/WeeklyProgressChart";
import { GoalTypeBreakdownChart } from "../../components/GoalTypeBreakdownChart"; import { GoalTypeBreakdownChart } from "../../components/GoalTypeBreakdownChart";
import { useUser, useAuth } from "@clerk/clerk-expo"; import { useUser } from "@clerk/clerk-expo";
import { import type { FitnessGoal, CreateGoalData } from "../../services/fitnessGoals";
fitnessGoalsService, import { useStatistics } from "../../contexts/StatisticsContext";
type FitnessGoal, import { useFitnessGoals } from "../../contexts/FitnessGoalsContext";
type CreateGoalData, import { useRecommendations } from "../../contexts/RecommendationsContext";
} from "../../services/fitnessGoals";
import { getUserStatistics } from "../../api/statistics";
import type { UserStatisticsResponse } from "../../api/types";
import { useFocusEffect } from "expo-router"; import { useFocusEffect } from "expo-router";
import * as SecureStore from "expo-secure-store"; import * as SecureStore from "expo-secure-store";
import log from "../../utils/logger"; import log from "../../utils/logger";
export default function GoalsScreen() { export default function GoalsScreen() {
const { user } = useUser(); const { user } = useUser();
const { getToken } = useAuth(); const {
const [goals, setGoals] = useState<FitnessGoal[]>([]); statistics,
const [statistics, setStatistics] = useState<UserStatisticsResponse | null>( refetchStatistics,
null, clearCache: clearStatsCache,
); } = useStatistics();
const {
goals,
loading,
refetchGoals,
createGoal,
completeGoal,
deleteGoal,
clearCache: clearGoalsCache,
} = useFitnessGoals();
const { clearCache: clearRecommendationsCache } = useRecommendations();
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [isModalVisible, setIsModalVisible] = useState(false); const [isModalVisible, setIsModalVisible] = useState(false);
const [showAnalytics, setShowAnalytics] = useState(false); const [showAnalytics, setShowAnalytics] = useState(false);
const fabScale = useRef(new Animated.Value(1)).current; const fabScale = useRef(new Animated.Value(1)).current;
const loadGoals = useCallback(async () => { const loadData = useCallback(async () => {
if (!user?.id) return; // Load goals and statistics (both cached)
try { await refetchGoals();
const token = await getToken(); await refetchStatistics();
log.debug("Token obtained", { }, [refetchGoals, refetchStatistics]);
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 clearClerkCache = async () => { const clearClerkCache = async () => {
Alert.alert( Alert.alert(
@ -110,6 +82,11 @@ export default function GoalsScreen() {
} }
} }
// Clear all caches
clearStatsCache();
clearGoalsCache();
clearRecommendationsCache();
Alert.alert( Alert.alert(
"Success", "Success",
"Cache cleared! Please sign out and sign back in.", "Cache cleared! Please sign out and sign back in.",
@ -126,37 +103,31 @@ export default function GoalsScreen() {
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
loadGoals(); loadData();
}, [loadGoals]), }, [loadData]),
); );
const onRefresh = async () => { const onRefresh = async () => {
setRefreshing(true); setRefreshing(true);
await loadGoals(); await loadData();
setRefreshing(false); setRefreshing(false);
}; };
const handleCreateGoal = async (newGoal: CreateGoalData) => { const handleCreateGoal = async (newGoal: CreateGoalData) => {
const token = await getToken(); await createGoal(newGoal);
await fitnessGoalsService.createGoal(newGoal, token);
await loadGoals();
setIsModalVisible(false); setIsModalVisible(false);
}; };
const handleCompleteGoal = async (goal: FitnessGoal) => { const handleCompleteGoal = async (goal: FitnessGoal) => {
const token = await getToken(); await completeGoal(goal.id);
await fitnessGoalsService.completeGoal(goal.id, token);
await loadGoals();
}; };
const handleDeleteGoal = async (goalId: string) => { const handleDeleteGoal = async (goalId: string) => {
const token = await getToken(); await deleteGoal(goalId);
await fitnessGoalsService.deleteGoal(goalId, token);
await loadGoals();
}; };
const activeGoals = goals.filter((g) => g.status === "active"); const activeGoals = goals?.filter((g) => g.status === "active") || [];
const completedGoals = goals.filter((g) => g.status === "completed"); const completedGoals = goals?.filter((g) => g.status === "completed") || [];
return ( return (
<View style={styles.container}> <View style={styles.container}>
@ -188,7 +159,7 @@ export default function GoalsScreen() {
</LinearGradient> </LinearGradient>
{/* Stats Summary */} {/* Stats Summary */}
{goals.length > 0 && ( {goals && goals.length > 0 && (
<View style={styles.statsContainer}> <View style={styles.statsContainer}>
<View style={styles.statCard}> <View style={styles.statCard}>
<Text style={styles.statValue}>{activeGoals.length}</Text> <Text style={styles.statValue}>{activeGoals.length}</Text>

View File

@ -14,46 +14,35 @@ import { Ionicons } from "@expo/vector-icons";
import { useUser } from "@clerk/clerk-expo"; import { useUser } from "@clerk/clerk-expo";
import { useFocusEffect } from "expo-router"; import { useFocusEffect } from "expo-router";
import { theme } from "../../styles/theme"; import { theme } from "../../styles/theme";
import { import { useRecommendations } from "../../contexts/RecommendationsContext";
getRecommendations, import type { Recommendation } from "../../api/recommendations";
generateRecommendation,
type Recommendation,
} from "../../api/recommendations";
import log from "../../utils/logger"; import log from "../../utils/logger";
export default function RecommendationsScreen() { export default function RecommendationsScreen() {
const { user } = useUser(); const { user } = useUser();
const [recommendations, setRecommendations] = useState<Recommendation[]>([]); const {
const [loading, setLoading] = useState(true); recommendations: allRecommendations,
loading,
refetchRecommendations,
generateNewRecommendation,
} = useRecommendations();
const [generating, setGenerating] = useState(false); const [generating, setGenerating] = useState(false);
const [refreshing, setRefreshing] = 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 // Filter to show only approved recommendations for regular users
const approved = data.filter((rec) => rec.status === "approved"); const recommendations = allRecommendations.filter(
setRecommendations(approved); (rec) => rec.status === "approved",
} catch (error) { );
log.error("Failed to load recommendations", error);
Alert.alert("Error", "Failed to load recommendations");
} finally {
setLoading(false);
}
}, [user?.id]);
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
loadRecommendations(); refetchRecommendations();
}, [loadRecommendations]), }, [refetchRecommendations]),
); );
const onRefresh = async () => { const onRefresh = async () => {
setRefreshing(true); setRefreshing(true);
await loadRecommendations(); await refetchRecommendations();
setRefreshing(false); setRefreshing(false);
}; };
@ -70,7 +59,7 @@ export default function RecommendationsScreen() {
onPress: async () => { onPress: async () => {
try { try {
setGenerating(true); setGenerating(true);
await generateRecommendation({ await generateNewRecommendation({
userId: user.id, userId: user.id,
modelProvider: "openai", modelProvider: "openai",
useExternalModel: true, useExternalModel: true,
@ -79,7 +68,7 @@ export default function RecommendationsScreen() {
"Success", "Success",
"AI recommendation generated! It will appear here once approved by your trainer.", "AI recommendation generated! It will appear here once approved by your trainer.",
); );
await loadRecommendations(); await refetchRecommendations();
} catch (error) { } catch (error) {
log.error("Failed to generate recommendation", error); log.error("Failed to generate recommendation", error);
Alert.alert( Alert.alert(

View File

@ -4,6 +4,9 @@ import * as SecureStore from "expo-secure-store";
import { View, Text } from "react-native"; import { View, Text } from "react-native";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { validateEnv } from "../utils/env"; 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"; import log from "../utils/logger";
// Validate environment variables on app startup // Validate environment variables on app startup
@ -150,11 +153,17 @@ export default function RootLayout() {
return ( return (
<ClerkProvider tokenCache={tokenCache} publishableKey={publishableKey}> <ClerkProvider tokenCache={tokenCache} publishableKey={publishableKey}>
<ClerkLoaded> <ClerkLoaded>
<StatisticsProvider>
<FitnessGoalsProvider>
<RecommendationsProvider>
<Stack> <Stack>
<Stack.Screen name="(auth)" options={{ headerShown: false }} /> <Stack.Screen name="(auth)" options={{ headerShown: false }} />
<Stack.Screen name="(tabs)" options={{ headerShown: false }} /> <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="welcome" options={{ headerShown: false }} /> <Stack.Screen name="welcome" options={{ headerShown: false }} />
</Stack> </Stack>
</RecommendationsProvider>
</FitnessGoalsProvider>
</StatisticsProvider>
</ClerkLoaded> </ClerkLoaded>
</ClerkProvider> </ClerkProvider>
); );

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect } from "react"; import React, { useEffect } from "react";
import { import {
View, View,
Text, Text,
@ -8,10 +8,8 @@ import {
} from "react-native"; } from "react-native";
import { LinearGradient } from "expo-linear-gradient"; import { LinearGradient } from "expo-linear-gradient";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { useUser } from "@clerk/clerk-expo";
import { theme } from "../styles/theme"; import { theme } from "../styles/theme";
import { getUserStatistics } from "../api/statistics"; import { useStatistics } from "../contexts/StatisticsContext";
import type { UserStatisticsResponse } from "../api/types";
const { width } = Dimensions.get("window"); const { width } = Dimensions.get("window");
@ -26,28 +24,11 @@ export function ActivityWidget({
calories, calories,
duration = 0, duration = 0,
}: ActivityWidgetProps) { }: ActivityWidgetProps) {
const { user } = useUser(); const { statistics, loading, refetchStatistics } = useStatistics();
const [statistics, setStatistics] = useState<UserStatisticsResponse | null>(
null,
);
const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
const loadStatistics = async () => { refetchStatistics();
if (!user?.id) return; }, [refetchStatistics]);
try {
const stats = await getUserStatistics(user.id);
setStatistics(stats);
} catch (error) {
console.error("Failed to load statistics:", error);
} finally {
setLoading(false);
}
};
loadStatistics();
}, [user?.id]);
// Calculate weekly activity bars from weekly trend data // Calculate weekly activity bars from weekly trend data
const getWeeklyBars = () => { const getWeeklyBars = () => {

View File

@ -3,33 +3,24 @@ import { View, Text, StyleSheet, ActivityIndicator } from "react-native";
import { LinearGradient } from "expo-linear-gradient"; import { LinearGradient } from "expo-linear-gradient";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { theme } from "../styles/theme"; import { theme } from "../styles/theme";
import { useUser } from "@clerk/clerk-expo"; import { useStatistics } from "../contexts/StatisticsContext";
import { getUserStatistics } from "../api/statistics";
import type { WeeklyTrendData } from "../api/types"; import type { WeeklyTrendData } from "../api/types";
export function WeeklyProgressWidget() { export function WeeklyProgressWidget() {
const { user } = useUser(); const { statistics, loading, refetchStatistics } = useStatistics();
const [weeklyData, setWeeklyData] = useState<WeeklyTrendData[]>([]); const [weeklyData, setWeeklyData] = useState<WeeklyTrendData[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
const loadWeeklyData = async () => { refetchStatistics();
if (!user?.id) return; }, [refetchStatistics]);
try { useEffect(() => {
const stats = await getUserStatistics(user.id); if (statistics?.weeklyTrend) {
// Get last 4 weeks for compact display // Get last 4 weeks for compact display
const last4Weeks = stats.weeklyTrend.slice(-4); const last4Weeks = statistics.weeklyTrend.slice(-4);
setWeeklyData(last4Weeks); setWeeklyData(last4Weeks);
} catch (error) {
console.error("Failed to load weekly data:", error);
} finally {
setLoading(false);
} }
}; }, [statistics]);
loadWeeklyData();
}, [user?.id]);
if (loading) { if (loading) {
return ( return (

View File

@ -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<void>;
createGoal: (goalData: CreateGoalData) => Promise<FitnessGoal>;
updateGoal: (
id: string,
updates: Partial<FitnessGoal>,
) => Promise<FitnessGoal>;
completeGoal: (id: string) => Promise<FitnessGoal>;
deleteGoal: (id: string) => Promise<void>;
clearCache: () => void;
}
const FitnessGoalsContext = createContext<FitnessGoalsContextValue | undefined>(
undefined,
);
export function FitnessGoalsProvider({
children,
}: {
children: React.ReactNode;
}) {
const { user } = useUser();
const { getToken } = useAuth();
const [goals, setGoals] = useState<FitnessGoal[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [lastFetchTime, setLastFetchTime] = useState<number>(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<FitnessGoal> => {
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<FitnessGoal>): Promise<FitnessGoal> => {
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<FitnessGoal> => {
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<void> => {
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 (
<FitnessGoalsContext.Provider
value={{
goals,
loading,
error,
refetchGoals,
createGoal,
updateGoal,
completeGoal,
deleteGoal,
clearCache,
}}
>
{children}
</FitnessGoalsContext.Provider>
);
}
export function useFitnessGoals() {
const context = useContext(FitnessGoalsContext);
if (context === undefined) {
throw new Error(
"useFitnessGoals must be used within a FitnessGoalsProvider",
);
}
return context;
}

View File

@ -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<void>;
generateNewRecommendation: (
data: GenerateRecommendationRequest,
) => Promise<Recommendation>;
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<Recommendation[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [lastFetchTime, setLastFetchTime] = useState<number>(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<Recommendation> => {
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 (
<RecommendationsContext.Provider
value={{
recommendations,
loading,
error,
refetchRecommendations,
generateNewRecommendation,
clearCache,
}}
>
{children}
</RecommendationsContext.Provider>
);
}
export function useRecommendations() {
const context = useContext(RecommendationsContext);
if (context === undefined) {
throw new Error(
"useRecommendations must be used within a RecommendationsProvider",
);
}
return context;
}

View File

@ -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<void>;
clearCache: () => void;
}
const StatisticsContext = createContext<StatisticsContextValue | undefined>(
undefined,
);
export function StatisticsProvider({
children,
}: {
children: React.ReactNode;
}) {
const { user } = useUser();
const { getToken } = useAuth();
const [statistics, setStatistics] = useState<UserStatisticsResponse | null>(
null,
);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [lastFetchTime, setLastFetchTime] = useState<number>(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 (
<StatisticsContext.Provider
value={{
statistics,
loading,
error,
refetchStatistics,
clearCache,
}}
>
{children}
</StatisticsContext.Provider>
);
}
export function useStatistics() {
const context = useContext(StatisticsContext);
if (context === undefined) {
throw new Error("useStatistics must be used within a StatisticsProvider");
}
return context;
}

View File

@ -72,7 +72,16 @@ export class FitnessGoalsService {
throw new Error(`Failed to fetch goals: ${response.status}`); 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) { } catch (error) {
log.error("Failed to fetch fitness goals", error); log.error("Failed to fetch fitness goals", error);
throw error; throw error;
@ -99,7 +108,15 @@ export class FitnessGoalsService {
throw new Error(error.error || "Failed to create goal"); 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) { } catch (error) {
log.error("Failed to create fitness goal", error); log.error("Failed to create fitness goal", error);
throw error; throw error;
@ -126,7 +143,15 @@ export class FitnessGoalsService {
throw new Error("Failed to update goal"); 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) { } catch (error) {
log.error("Failed to update fitness goal", error); log.error("Failed to update fitness goal", error);
throw error; throw error;
@ -156,7 +181,16 @@ export class FitnessGoalsService {
throw new Error("Failed to complete goal"); 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) { } catch (error) {
log.error("Failed to complete fitness goal", error); log.error("Failed to complete fitness goal", error);
throw error; throw error;
@ -177,6 +211,9 @@ export class FitnessGoalsService {
if (!response.ok) { if (!response.ok) {
throw new Error("Failed to delete goal"); 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) { } catch (error) {
log.error("Failed to delete fitness goal", error); log.error("Failed to delete fitness goal", error);
throw error; throw error;