api call optimized
This commit is contained in:
parent
97436c6823
commit
612259f020
@ -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 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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 = () => {
|
||||||
|
|||||||
@ -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 (
|
||||||
|
|||||||
204
apps/mobile/src/contexts/FitnessGoalsContext.tsx
Normal file
204
apps/mobile/src/contexts/FitnessGoalsContext.tsx
Normal 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;
|
||||||
|
}
|
||||||
141
apps/mobile/src/contexts/RecommendationsContext.tsx
Normal file
141
apps/mobile/src/contexts/RecommendationsContext.tsx
Normal 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;
|
||||||
|
}
|
||||||
97
apps/mobile/src/contexts/StatisticsContext.tsx
Normal file
97
apps/mobile/src/contexts/StatisticsContext.tsx
Normal 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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user