From a65b3cac0832c5810c2248ca635c6615999a8b1d Mon Sep 17 00:00:00 2001 From: echo Date: Tue, 31 Mar 2026 18:51:30 +0200 Subject: [PATCH] add client self-service ai activity plans on goals screen --- .../recommendations/generate-self/route.ts | 190 +++++++++++++++++ apps/mobile/src/api/recommendations.ts | 23 +++ apps/mobile/src/app/(tabs)/goals.tsx | 194 +++++++++++++++++- .../src/components/GoalProgressCard.tsx | 6 + .../src/contexts/RecommendationsContext.tsx | 15 ++ 5 files changed, 426 insertions(+), 2 deletions(-) create mode 100644 apps/admin/src/app/api/recommendations/generate-self/route.ts diff --git a/apps/admin/src/app/api/recommendations/generate-self/route.ts b/apps/admin/src/app/api/recommendations/generate-self/route.ts new file mode 100644 index 0000000..20d2a90 --- /dev/null +++ b/apps/admin/src/app/api/recommendations/generate-self/route.ts @@ -0,0 +1,190 @@ +import { NextResponse } from "next/server"; +import { auth } from "@clerk/nextjs/server"; +import { getDatabase } from "@/lib/database"; +import { buildAIContext } from "@/lib/ai/ai-context"; +import { buildEnhancedPrompt, buildBasicPrompt } from "@/lib/ai/prompt-builder"; +import log from "@/lib/logger"; +import { ensureUserSynced } from "@/lib/sync-user"; +import { getUserMembershipContext } from "@/lib/membership/access"; + +export async function POST() { + try { + const { userId } = await auth(); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const db = await getDatabase(); + const currentUser = await ensureUserSynced(userId, db); + + if (!currentUser) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + if (currentUser.role !== "client") { + return NextResponse.json( + { error: "Only clients can self-generate AI plans" }, + { status: 403 }, + ); + } + + const { membershipType, features } = await getUserMembershipContext(userId); + + if (membershipType === "basic") { + return NextResponse.json( + { + error: + "AI plan generation is available on Premium and VIP memberships", + membershipType, + }, + { status: 403 }, + ); + } + + if (features.recommendationsPerMonth > 0) { + const currentMonth = new Date(); + const monthStart = new Date( + currentMonth.getFullYear(), + currentMonth.getMonth(), + 1, + ); + const monthEnd = new Date( + currentMonth.getFullYear(), + currentMonth.getMonth() + 1, + 1, + ); + + const existingRecommendations = + await db.getRecommendationsByUserId(userId); + const recommendationsThisMonth = existingRecommendations.filter( + (recommendation) => + recommendation.generatedAt >= monthStart && + recommendation.generatedAt < monthEnd, + ).length; + + if (recommendationsThisMonth >= features.recommendationsPerMonth) { + return NextResponse.json( + { + error: `Your ${membershipType} plan includes ${features.recommendationsPerMonth} AI recommendation(s) per month`, + membershipType, + }, + { status: 403 }, + ); + } + } + + const profile = await db.getFitnessProfileByUserId(userId); + if (!profile) { + return NextResponse.json( + { error: "Complete your fitness profile before generating a plan" }, + { status: 404 }, + ); + } + + let prompt: string; + try { + const context = await buildAIContext(userId); + prompt = buildEnhancedPrompt(context); + } catch (error) { + log.warn("Failed to build AI context for self-generate", { + userId, + error: error instanceof Error ? error.message : String(error), + }); + prompt = buildBasicPrompt(profile); + } + + const openaiApiKey = process.env.OPENAI_API_KEY; + if (!openaiApiKey) { + return NextResponse.json( + { error: "AI provider unavailable. Try again later." }, + { status: 503 }, + ); + } + + const openaiResponse = await fetch( + "https://api.openai.com/v1/chat/completions", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${openaiApiKey}`, + }, + body: JSON.stringify({ + model: "gpt-4o-mini", + messages: [ + { + role: "system", + content: + 'You are a professional fitness trainer and nutritionist. Always respond with valid JSON only, no markdown or code blocks. The response must have this exact structure: {"recommendationText": "string", "activityPlan": "string", "dietPlan": "string"}', + }, + { + role: "user", + content: prompt, + }, + ], + temperature: 0.7, + max_tokens: 1500, + response_format: { type: "json_object" }, + }), + }, + ); + + if (!openaiResponse.ok) { + const errorText = await openaiResponse.text(); + log.error("OpenAI self-generate request failed", new Error(errorText), { + userId, + status: openaiResponse.status, + }); + return NextResponse.json( + { error: "Failed to generate AI plan" }, + { status: 500 }, + ); + } + + const openaiData = await openaiResponse.json(); + + let parsedResponse: { + recommendationText?: string; + activityPlan?: string; + dietPlan?: string; + }; + + try { + parsedResponse = JSON.parse(openaiData.choices[0].message.content); + } catch (e) { + log.error("Failed to parse self-generate OpenAI response", e, { + userId, + }); + return NextResponse.json( + { error: "Invalid response from AI provider" }, + { status: 500 }, + ); + } + + const recommendation = await db.createRecommendation({ + id: crypto.randomUUID(), + userId, + fitnessProfileId: profile.id, + recommendationText: parsedResponse.recommendationText || "", + activityPlan: parsedResponse.activityPlan || "", + dietPlan: parsedResponse.dietPlan || "", + status: "approved", + generatedAt: new Date(), + updatedAt: new Date(), + }); + + return NextResponse.json({ + success: true, + data: recommendation, + meta: { + timestamp: new Date().toISOString(), + }, + }); + } catch (error) { + log.error("Failed to self-generate recommendation", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } +} diff --git a/apps/mobile/src/api/recommendations.ts b/apps/mobile/src/api/recommendations.ts index a7796d4..7c91ea1 100644 --- a/apps/mobile/src/api/recommendations.ts +++ b/apps/mobile/src/api/recommendations.ts @@ -109,3 +109,26 @@ export async function approveRecommendation( throw error; } } + +/** + * Generate AI recommendation for the authenticated client user + */ +export async function generateSelfRecommendation( + token: string | null, +): Promise { + try { + const response = await apiClient.post( + `${API_ENDPOINTS.RECOMMENDATIONS}/generate-self`, + {}, + withAuth(token), + ); + return parseApiData(response.data); + } catch (error) { + if (isAxiosError(error) && error.response) { + throw new Error( + `Failed to generate recommendation: ${error.response.status}`, + ); + } + throw error; + } +} diff --git a/apps/mobile/src/app/(tabs)/goals.tsx b/apps/mobile/src/app/(tabs)/goals.tsx index b1cdf6e..e5bb22d 100644 --- a/apps/mobile/src/app/(tabs)/goals.tsx +++ b/apps/mobile/src/app/(tabs)/goals.tsx @@ -22,6 +22,7 @@ import { GoalProgressCard } from "../../components/GoalProgressCard"; import { GoalCreationModal } from "../../components/GoalCreationModal"; import { WeeklyProgressChart } from "../../components/WeeklyProgressChart"; import { GoalTypeBreakdownChart } from "../../components/GoalTypeBreakdownChart"; +import { useMembership } from "../../hooks/useMembership"; import type { FitnessGoal, CreateGoalData } from "../../services/fitnessGoals"; import { useStatistics } from "../../contexts/StatisticsContext"; import { useFitnessGoals } from "../../contexts/FitnessGoalsContext"; @@ -45,15 +46,24 @@ export default function GoalsScreen() { deleteGoal, clearCache: clearGoalsCache, } = useFitnessGoals(); - const { clearCache: clearRecommendationsCache } = useRecommendations(); + const { + recommendations, + clearCache: clearRecommendationsCache, + refetchRecommendations, + generateSelfPlan, + } = useRecommendations(); + const { membershipType, features } = useMembership(); const [refreshing, setRefreshing] = useState(false); const [isModalVisible, setIsModalVisible] = useState(false); const [showAnalytics, setShowAnalytics] = useState(false); + const [isGeneratingPlan, setIsGeneratingPlan] = useState(false); + const [showFullPlan, setShowFullPlan] = useState(false); const loadData = useCallback(async () => { await refetchGoals(); + await refetchRecommendations(); await refetchStatistics(); - }, [refetchGoals, refetchStatistics]); + }, [refetchGoals, refetchRecommendations, refetchStatistics]); const clearClerkCache = async () => { Alert.alert( @@ -136,6 +146,75 @@ export default function GoalsScreen() { ) : 0; + const latestApprovedRecommendation = [...recommendations] + .filter((rec) => rec.status === "approved") + .sort( + (a, b) => + new Date(b.generatedAt).getTime() - new Date(a.generatedAt).getTime(), + )[0]; + + const isGoalAiAligned = (goal: FitnessGoal) => { + if (!latestApprovedRecommendation?.activityPlan) return false; + + const planText = latestApprovedRecommendation.activityPlan.toLowerCase(); + const title = goal.title.toLowerCase(); + const description = (goal.description || "").toLowerCase(); + + const content = `${title} ${description}`; + + if (goal.goalType === "strength_milestone") { + return planText.includes("strength") || planText.includes("weight"); + } + if (goal.goalType === "endurance_target") { + return ( + planText.includes("cardio") || + planText.includes("endurance") || + planText.includes("run") + ); + } + if (goal.goalType === "flexibility_goal") { + return planText.includes("stretch") || planText.includes("mobility"); + } + if (goal.goalType === "habit_building") { + return planText.includes("habit") || planText.includes("daily"); + } + + return ( + planText.includes(content.split(" ")[0] || "") || + content + .split(" ") + .some((word) => word.length > 4 && planText.includes(word)) + ); + }; + + const handleGenerateAiPlan = async () => { + if (!features.recommendationsPerMonth || membershipType === "basic") { + Alert.alert( + "Premium Feature", + "AI plan generation is available on Premium and VIP memberships.", + ); + return; + } + + try { + setIsGeneratingPlan(true); + await generateSelfPlan(); + await refetchRecommendations(); + Alert.alert( + "Plan Ready", + "Your new AI activity plan has been generated.", + ); + } catch (error) { + const message = + error instanceof Error + ? error.message + : "Failed to generate AI activity plan."; + Alert.alert("Could not generate plan", message); + } finally { + setIsGeneratingPlan(false); + } + }; + return ( + + + + + + + + + AI Activity Plan + + + {latestApprovedRecommendation + ? `Generated ${new Date(latestApprovedRecommendation.generatedAt).toLocaleDateString()}` + : "Generate a plan aligned to your fitness profile"} + + + + + + + {latestApprovedRecommendation ? ( + <> + + {latestApprovedRecommendation.activityPlan} + + setShowFullPlan((prev) => !prev)} + style={styles.showPlanButton} + > + + {showFullPlan ? "Show less" : "View full plan"} + + + + ) : ( + + No AI activity plan yet. Generate one to get personalized + guidance. + + )} + + + + handleCompleteGoal(goal)} onDelete={() => handleDeleteGoal(goal.id)} /> @@ -509,6 +672,33 @@ const styles = StyleSheet.create({ borderTopWidth: 1, borderTopColor: "rgba(0,0,0,0.05)", }, + aiPlanCard: { + padding: 18, + marginBottom: 16, + }, + aiPlanHeader: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + }, + aiPlanHeaderLeft: { + flexDirection: "row", + alignItems: "center", + flex: 1, + marginRight: 12, + }, + aiPlanIcon: { + width: 40, + height: 40, + borderRadius: 12, + justifyContent: "center", + alignItems: "center", + marginRight: 12, + }, + showPlanButton: { + marginTop: 8, + alignSelf: "flex-start", + }, chartSection: { marginBottom: 20, }, diff --git a/apps/mobile/src/components/GoalProgressCard.tsx b/apps/mobile/src/components/GoalProgressCard.tsx index a8d1999..017dbf2 100644 --- a/apps/mobile/src/components/GoalProgressCard.tsx +++ b/apps/mobile/src/components/GoalProgressCard.tsx @@ -20,6 +20,7 @@ interface GoalProgressCardProps { onPress?: () => void; onComplete?: () => void; onDelete?: () => void; + aiAligned?: boolean; } export function GoalProgressCard({ @@ -27,6 +28,7 @@ export function GoalProgressCard({ onPress, onComplete, onDelete, + aiAligned = false, }: GoalProgressCardProps) { const { colors, typography } = useTheme(); const isCompleted = goal.status === "completed"; @@ -277,6 +279,10 @@ export function GoalProgressCard({ )} + {aiAligned && !isCompleted && ( + + )} + {isCompleted && goal.completedDate && ( Completed {new Date(goal.completedDate).toLocaleDateString()} diff --git a/apps/mobile/src/contexts/RecommendationsContext.tsx b/apps/mobile/src/contexts/RecommendationsContext.tsx index 211afde..e436513 100644 --- a/apps/mobile/src/contexts/RecommendationsContext.tsx +++ b/apps/mobile/src/contexts/RecommendationsContext.tsx @@ -10,6 +10,7 @@ import { useUser, useAuth } from "@clerk/clerk-expo"; import { getRecommendations, generateRecommendation, + generateSelfRecommendation, type Recommendation, type GenerateRecommendationRequest, } from "../api/recommendations"; @@ -23,6 +24,7 @@ interface RecommendationsContextValue { generateNewRecommendation: ( data: GenerateRecommendationRequest, ) => Promise; + generateSelfPlan: () => Promise; clearCache: () => void; } @@ -107,6 +109,18 @@ export function RecommendationsProvider({ [user?.id, getToken], ); + const generateSelfPlan = useCallback(async (): Promise => { + if (!user?.id) throw new Error("User not authenticated"); + + const token = await getToken(); + const recommendation = await generateSelfRecommendation(token); + + setRecommendations((prev) => [recommendation, ...prev]); + setLastFetchTime(Date.now()); + + return recommendation; + }, [user?.id, getToken]); + const clearCache = useCallback(() => { setRecommendations([]); setLoading(false); @@ -133,6 +147,7 @@ export function RecommendationsProvider({ error, refetchRecommendations, generateNewRecommendation, + generateSelfPlan, clearCache, }} >