diff --git a/apps/admin/data/fitai.db b/apps/admin/data/fitai.db index 896526b..d915859 100644 Binary files a/apps/admin/data/fitai.db and b/apps/admin/data/fitai.db differ diff --git a/apps/admin/src/app/api/recommendations/generate-self/route.ts b/apps/admin/src/app/api/recommendations/generate-self/route.ts new file mode 100644 index 0000000..3755138 --- /dev/null +++ b/apps/admin/src/app/api/recommendations/generate-self/route.ts @@ -0,0 +1,455 @@ +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"; + +const AI_LINK_PREFIX = "[AI_LINKED]"; + +interface ParsedPlanItem { + id: string; + title: string; + description: string; + goalType: + | "weight_target" + | "strength_milestone" + | "endurance_target" + | "flexibility_goal" + | "habit_building" + | "custom"; +} + +interface GeneratedPlanContent { + recommendationText?: string; + activityPlan?: string; + dietPlan?: string; +} + +function buildFallbackPlan(profile: { + activityLevel?: string; + fitnessGoals?: string[] | string; + medicalConditions?: string; +}): GeneratedPlanContent { + const goals = Array.isArray(profile.fitnessGoals) + ? profile.fitnessGoals + : typeof profile.fitnessGoals === "string" && profile.fitnessGoals + ? [profile.fitnessGoals] + : ["general fitness"]; + + const primaryGoal = goals[0] || "general fitness"; + const activityLevel = profile.activityLevel || "moderate"; + const hasMedicalNotes = Boolean(profile.medicalConditions?.trim()); + + return { + recommendationText: + `Personalized starter plan focused on ${primaryGoal} with ${activityLevel} activity pacing.` + + (hasMedicalNotes + ? " Medical notes detected, so keep intensity conservative and progress gradually." + : ""), + activityPlan: + "- 3 strength sessions per week (full-body, 35-45 min)\n" + + "- 2 cardio sessions per week (20-30 min brisk walk/run/cycle)\n" + + "- 10 minutes daily mobility/stretching after workouts\n" + + "- 1 full recovery day each week", + dietPlan: + "- Build meals around lean protein, vegetables, whole grains, and hydration\n" + + "- Keep portions consistent and avoid skipping meals\n" + + "- Track intake daily and adjust calories based on weekly progress", + }; +} + +function inferGoalType(text: string): ParsedPlanItem["goalType"] { + const normalized = text.toLowerCase(); + + if ( + normalized.includes("strength") || + normalized.includes("bench") || + normalized.includes("squat") || + normalized.includes("deadlift") || + normalized.includes("weights") + ) { + return "strength_milestone"; + } + + if ( + normalized.includes("run") || + normalized.includes("cardio") || + normalized.includes("endurance") || + normalized.includes("cycle") + ) { + return "endurance_target"; + } + + if ( + normalized.includes("stretch") || + normalized.includes("mobility") || + normalized.includes("yoga") || + normalized.includes("flexibility") + ) { + return "flexibility_goal"; + } + + if ( + normalized.includes("daily") || + normalized.includes("routine") || + normalized.includes("habit") + ) { + return "habit_building"; + } + + return "custom"; +} + +function parseActivityPlanToItems(activityPlan: string): ParsedPlanItem[] { + const lines = activityPlan + .replace(/\r\n/g, "\n") + .split(/\n+/) + .flatMap((line) => line.split(/(?<=[.!?])\s+(?=[A-Z0-9])/g)) + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => line.replace(/^[-*•\d.)\s]+/, "").trim()) + .filter((line) => line.length > 10) + .slice(0, 8); + + const uniqueLines = Array.from(new Set(lines)); + + return uniqueLines.map((line) => ({ + id: crypto.randomUUID(), + title: line.length > 72 ? `${line.slice(0, 69)}...` : line, + description: line, + goalType: inferGoalType(line), + })); +} + +function getDefaultPlanItems(): ParsedPlanItem[] { + const defaults = [ + "Complete 3 strength sessions this week with progressive overload.", + "Add 2 cardio sessions of 25-30 minutes for endurance.", + "Do a 10-minute mobility routine daily after training.", + ]; + + return defaults.map((line) => ({ + id: crypto.randomUUID(), + title: line.length > 72 ? `${line.slice(0, 69)}...` : line, + description: line, + goalType: inferGoalType(line), + })); +} + +function parseJsonPayload(content: string): GeneratedPlanContent { + let cleanResponse = content.trim(); + + if (cleanResponse.startsWith("```json")) { + cleanResponse = cleanResponse + .replace(/^```json\s*/, "") + .replace(/\s*```$/, ""); + } else if (cleanResponse.startsWith("```")) { + cleanResponse = cleanResponse.replace(/^```\s*/, "").replace(/\s*```$/, ""); + } + + const firstBrace = cleanResponse.indexOf("{"); + const lastBrace = cleanResponse.lastIndexOf("}"); + if (firstBrace !== -1 && lastBrace !== -1) { + cleanResponse = cleanResponse.substring(firstBrace, lastBrace + 1); + } + + return JSON.parse(cleanResponse) as GeneratedPlanContent; +} + +async function generateWithOpenAI( + openaiApiKey: string, + prompt: string, +): Promise { + const response = 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 (!response.ok) { + const errorText = await response.text(); + throw new Error(`OpenAI failed: ${response.status} ${errorText}`); + } + + const data = await response.json(); + return parseJsonPayload(data.choices[0].message.content as string); +} + +async function generateWithDeepSeek( + deepseekApiKey: string, + prompt: string, +): Promise { + const response = await fetch("https://api.deepseek.com/v1/chat/completions", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${deepseekApiKey}`, + }, + body: JSON.stringify({ + model: "deepseek-chat", + messages: [ + { + role: "system", + content: + "You are a professional fitness trainer and nutritionist. Always respond with valid JSON only, no markdown or code blocks.", + }, + { + role: "user", + content: prompt, + }, + ], + temperature: 0.7, + max_tokens: 1200, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`DeepSeek failed: ${response.status} ${errorText}`); + } + + const data = await response.json(); + return parseJsonPayload(data.choices[0].message.content as string); +} + +async function generateWithOllama( + prompt: string, +): Promise { + const response = await fetch("http://localhost:11434/api/generate", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: "gemma3:latest", + prompt, + stream: false, + format: "json", + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Ollama failed: ${response.status} ${errorText}`); + } + + const data = await response.json(); + return parseJsonPayload(data.response as string); +} + +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 }); + } + + 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; + const deepseekApiKey = process.env.DEEPSEEK_API_KEY; + + let parsedResponse: GeneratedPlanContent; + let usedFallbackPlan = false; + + try { + if (openaiApiKey) { + parsedResponse = await generateWithOpenAI(openaiApiKey, prompt); + } else if (deepseekApiKey) { + parsedResponse = await generateWithDeepSeek(deepseekApiKey, prompt); + } else { + parsedResponse = await generateWithOllama(prompt); + } + } catch (providerError) { + log.error("Self-generate provider failed", providerError, { + userId, + hasOpenAI: Boolean(openaiApiKey), + hasDeepSeek: Boolean(deepseekApiKey), + }); + + parsedResponse = buildFallbackPlan({ + activityLevel: profile.activityLevel, + fitnessGoals: profile.fitnessGoals, + medicalConditions: profile.medicalConditions, + }); + usedFallbackPlan = true; + } + + 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(), + }); + + const existingActiveGoals = await db.getFitnessGoalsByUserId( + userId, + "active", + ); + const linkedGoals = existingActiveGoals.filter((goal) => + goal.notes?.startsWith(AI_LINK_PREFIX), + ); + + await Promise.all( + linkedGoals.map((goal) => + db.updateFitnessGoal(goal.id, { + status: "paused", + notes: `${goal.notes || ""}\nPaused due to new AI plan generation on ${new Date().toISOString()}`, + }), + ), + ); + + let planItems = parseActivityPlanToItems(parsedResponse.activityPlan || ""); + + if (planItems.length === 0 && parsedResponse.recommendationText) { + planItems = parseActivityPlanToItems(parsedResponse.recommendationText); + } + + if (planItems.length === 0) { + planItems = getDefaultPlanItems(); + } + + log.debug("AI plan parsed into goal items", { + recommendationId: recommendation.id, + userId, + parsedItems: planItems.length, + }); + + const createdGoals = await Promise.all( + planItems.map((item) => + db.createFitnessGoal({ + id: crypto.randomUUID(), + userId, + fitnessProfileId: profile.id, + goalType: item.goalType, + title: item.title, + description: item.description, + targetValue: undefined, + currentValue: 0, + unit: undefined, + startDate: new Date(), + targetDate: undefined, + completedDate: undefined, + status: "active", + progress: 0, + priority: "medium", + notes: `${AI_LINK_PREFIX} recommendationId=${recommendation.id}; itemId=${item.id}`, + }), + ), + ); + + return NextResponse.json({ + success: true, + data: recommendation, + meta: { + timestamp: new Date().toISOString(), + createdGoals: createdGoals.length, + pausedGoals: linkedGoals.length, + usedFallbackPlan, + }, + }); + } 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..9bffc3c 100644 --- a/apps/mobile/src/api/recommendations.ts +++ b/apps/mobile/src/api/recommendations.ts @@ -18,6 +18,16 @@ export interface Recommendation { updatedAt: string; } +interface RecommendationMeta { + usedFallbackPlan?: boolean; +} + +interface RecommendationApiEnvelope { + success?: boolean; + data?: Recommendation; + meta?: RecommendationMeta; +} + export interface GenerateRecommendationRequest { userId: string; useExternalModel?: boolean; @@ -109,3 +119,36 @@ 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)) { + const responseError = error.response?.data as + | { error?: string } + | undefined; + + if (responseError?.error) { + throw new Error(responseError.error); + } + + if (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..ea7e7ec 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,76 @@ 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 (goal.notes?.startsWith("[AI_LINKED]")) return true; + 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 Promise.all([refetchRecommendations(), refetchGoals()]); + Alert.alert( + "Plan Ready", + "Your new activity plan is ready and active goals were added.", + ); + } 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 +673,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, }} >