From a65b3cac0832c5810c2248ca635c6615999a8b1d Mon Sep 17 00:00:00 2001 From: echo Date: Tue, 31 Mar 2026 18:51:30 +0200 Subject: [PATCH 1/6] 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, }} > From e119f0923c7c64a5833ac470c19e908333d49974 Mon Sep 17 00:00:00 2001 From: echo Date: Tue, 31 Mar 2026 19:10:17 +0200 Subject: [PATCH 2/6] pause previous ai-linked goals on self plan regeneration --- .../recommendations/generate-self/route.ts | 122 ++++++++++++++++++ apps/mobile/src/app/(tabs)/goals.tsx | 1 + 2 files changed, 123 insertions(+) diff --git a/apps/admin/src/app/api/recommendations/generate-self/route.ts b/apps/admin/src/app/api/recommendations/generate-self/route.ts index 20d2a90..abf9f71 100644 --- a/apps/admin/src/app/api/recommendations/generate-self/route.ts +++ b/apps/admin/src/app/api/recommendations/generate-self/route.ts @@ -7,6 +7,82 @@ 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"; +} + +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 + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => line.replace(/^[-*•\d.)\s]+/, "").trim()) + .filter((line) => line.length > 4) + .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), + })); +} + export async function POST() { try { const { userId } = await auth(); @@ -173,11 +249,57 @@ export async function POST() { 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()}`, + }), + ), + ); + + const planItems = parseActivityPlanToItems( + parsedResponse.activityPlan || "", + ); + + 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, }, }); } catch (error) { diff --git a/apps/mobile/src/app/(tabs)/goals.tsx b/apps/mobile/src/app/(tabs)/goals.tsx index e5bb22d..9b37058 100644 --- a/apps/mobile/src/app/(tabs)/goals.tsx +++ b/apps/mobile/src/app/(tabs)/goals.tsx @@ -154,6 +154,7 @@ export default function GoalsScreen() { )[0]; const isGoalAiAligned = (goal: FitnessGoal) => { + if (goal.notes?.startsWith("[AI_LINKED]")) return true; if (!latestApprovedRecommendation?.activityPlan) return false; const planText = latestApprovedRecommendation.activityPlan.toLowerCase(); From c877577fbaa8b730add43b2f4f086c982eaa2dac Mon Sep 17 00:00:00 2001 From: echo Date: Tue, 31 Mar 2026 19:21:00 +0200 Subject: [PATCH 3/6] fix ai activity plan conversion and immediate goals refresh --- .../recommendations/generate-self/route.ts | 39 ++++++++++++++++--- apps/mobile/src/app/(tabs)/goals.tsx | 4 +- 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/apps/admin/src/app/api/recommendations/generate-self/route.ts b/apps/admin/src/app/api/recommendations/generate-self/route.ts index abf9f71..e07dab3 100644 --- a/apps/admin/src/app/api/recommendations/generate-self/route.ts +++ b/apps/admin/src/app/api/recommendations/generate-self/route.ts @@ -66,11 +66,13 @@ function inferGoalType(text: string): ParsedPlanItem["goalType"] { function parseActivityPlanToItems(activityPlan: string): ParsedPlanItem[] { const lines = activityPlan - .split("\n") + .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 > 4) + .filter((line) => line.length > 10) .slice(0, 8); const uniqueLines = Array.from(new Set(lines)); @@ -83,6 +85,21 @@ function parseActivityPlanToItems(activityPlan: string): ParsedPlanItem[] { })); } +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), + })); +} + export async function POST() { try { const { userId } = await auth(); @@ -266,9 +283,21 @@ export async function POST() { ), ); - const planItems = parseActivityPlanToItems( - parsedResponse.activityPlan || "", - ); + 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) => diff --git a/apps/mobile/src/app/(tabs)/goals.tsx b/apps/mobile/src/app/(tabs)/goals.tsx index 9b37058..af76f20 100644 --- a/apps/mobile/src/app/(tabs)/goals.tsx +++ b/apps/mobile/src/app/(tabs)/goals.tsx @@ -200,10 +200,10 @@ export default function GoalsScreen() { try { setIsGeneratingPlan(true); await generateSelfPlan(); - await refetchRecommendations(); + await Promise.all([refetchRecommendations(), refetchGoals()]); Alert.alert( "Plan Ready", - "Your new AI activity plan has been generated.", + "Your new AI activity plan has been generated and active goals were added.", ); } catch (error) { const message = From 73218402f689f1c831ce945ff3488c38504719cb Mon Sep 17 00:00:00 2001 From: echo Date: Tue, 31 Mar 2026 19:31:45 +0200 Subject: [PATCH 4/6] fix self ai plan generation authorization and error handling --- .../api/recommendations/generate-self/route.ts | 7 ------- apps/mobile/src/api/recommendations.ts | 18 ++++++++++++++---- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/apps/admin/src/app/api/recommendations/generate-self/route.ts b/apps/admin/src/app/api/recommendations/generate-self/route.ts index e07dab3..9af6320 100644 --- a/apps/admin/src/app/api/recommendations/generate-self/route.ts +++ b/apps/admin/src/app/api/recommendations/generate-self/route.ts @@ -114,13 +114,6 @@ export async function POST() { 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") { diff --git a/apps/mobile/src/api/recommendations.ts b/apps/mobile/src/api/recommendations.ts index 7c91ea1..b82f2d1 100644 --- a/apps/mobile/src/api/recommendations.ts +++ b/apps/mobile/src/api/recommendations.ts @@ -124,10 +124,20 @@ export async function generateSelfRecommendation( ); return parseApiData(response.data); } catch (error) { - if (isAxiosError(error) && error.response) { - throw new Error( - `Failed to generate recommendation: ${error.response.status}`, - ); + 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; } From ef9f39e564672c4f3db008fee4ee391edbb33fef Mon Sep 17 00:00:00 2001 From: echo Date: Tue, 31 Mar 2026 19:43:19 +0200 Subject: [PATCH 5/6] add resilient self-plan generation with provider fallback --- .../recommendations/generate-self/route.ts | 241 +++++++++++++----- apps/mobile/src/api/recommendations.ts | 12 +- apps/mobile/src/app/(tabs)/goals.tsx | 2 +- 3 files changed, 193 insertions(+), 62 deletions(-) diff --git a/apps/admin/src/app/api/recommendations/generate-self/route.ts b/apps/admin/src/app/api/recommendations/generate-self/route.ts index 9af6320..3755138 100644 --- a/apps/admin/src/app/api/recommendations/generate-self/route.ts +++ b/apps/admin/src/app/api/recommendations/generate-self/route.ts @@ -22,6 +22,45 @@ interface ParsedPlanItem { | "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(); @@ -100,6 +139,126 @@ function getDefaultPlanItems(): ParsedPlanItem[] { })); } +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(); @@ -180,71 +339,32 @@ export async function POST() { } const openaiApiKey = process.env.OPENAI_API_KEY; - if (!openaiApiKey) { - return NextResponse.json( - { error: "AI provider unavailable. Try again later." }, - { status: 503 }, - ); - } + const deepseekApiKey = process.env.DEEPSEEK_API_KEY; - 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; - }; + let parsedResponse: GeneratedPlanContent; + let usedFallbackPlan = false; try { - parsedResponse = JSON.parse(openaiData.choices[0].message.content); - } catch (e) { - log.error("Failed to parse self-generate OpenAI response", e, { + 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), }); - return NextResponse.json( - { error: "Invalid response from AI provider" }, - { status: 500 }, - ); + + parsedResponse = buildFallbackPlan({ + activityLevel: profile.activityLevel, + fitnessGoals: profile.fitnessGoals, + medicalConditions: profile.medicalConditions, + }); + usedFallbackPlan = true; } const recommendation = await db.createRecommendation({ @@ -322,6 +442,7 @@ export async function POST() { timestamp: new Date().toISOString(), createdGoals: createdGoals.length, pausedGoals: linkedGoals.length, + usedFallbackPlan, }, }); } catch (error) { diff --git a/apps/mobile/src/api/recommendations.ts b/apps/mobile/src/api/recommendations.ts index b82f2d1..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; @@ -117,7 +127,7 @@ export async function generateSelfRecommendation( token: string | null, ): Promise { try { - const response = await apiClient.post( + const response = await apiClient.post( `${API_ENDPOINTS.RECOMMENDATIONS}/generate-self`, {}, withAuth(token), diff --git a/apps/mobile/src/app/(tabs)/goals.tsx b/apps/mobile/src/app/(tabs)/goals.tsx index af76f20..ea7e7ec 100644 --- a/apps/mobile/src/app/(tabs)/goals.tsx +++ b/apps/mobile/src/app/(tabs)/goals.tsx @@ -203,7 +203,7 @@ export default function GoalsScreen() { await Promise.all([refetchRecommendations(), refetchGoals()]); Alert.alert( "Plan Ready", - "Your new AI activity plan has been generated and active goals were added.", + "Your new activity plan is ready and active goals were added.", ); } catch (error) { const message = From 178ad3fa90ad609e97896e5eb2ed8bee4b2530fb Mon Sep 17 00:00:00 2001 From: echo Date: Tue, 31 Mar 2026 19:44:55 +0200 Subject: [PATCH 6/6] db --- apps/admin/data/fitai.db | Bin 262144 -> 266240 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/apps/admin/data/fitai.db b/apps/admin/data/fitai.db index 896526b616a0a4b0cb21cd1d5ed3c5d4e722ab32..d91585944b715734aa780e298fe5ce9fb664d6c1 100644 GIT binary patch delta 4477 zcmcInU2GKB72dHI*7lkmLI}a6gu5lEcEjDy|Bnf6F{Bt_1I4nciXw97&Yktdvoo8S zS&S96i-}0nJTy++Xb@^dAM((*sIn9%Y1OEGs3Mi7s#K|YXi*7LsnYcCg;Z)4^~~&g z!4!f;!^5n0=H59o=bP_*=R2oQuRr~R_45y{`5>3e?RgP>iTmJ3PrUlthBf?ExRBe! z|C_(Y|C9fazq(jn|I4+DmtSAoy9O4%+_&)I+ncu(OJB^D=J{v&*Glj6$NBe4)zX_h zT;!F-GD4+=`FHb&995GP+0n3RnhsVRnPJ0F7$%k=OO{Aw#juNm-IJVY2~@I8ibaDl ztk|NCP1#Vfq&SXfN`}Bp*0(tSZp>{d=C$0q$JSlst-|y;e(nb^qwWBPGktU#s(ZIJOWU+XLF&~9N15PV0he zM4Y`Z31`t*!>_Vv7>NQTQ;$%cg~(&o$||P1N@QE2%)+`(CEyX$F)>5~ z3yNeZg6ar@PCLLn)Sm&fYu5^3RB9TAYMF44A!5aJbilSGYuIoE$utD2Q8jsL5BK>t zNsf5LpGKpBXAdJqUFJ&VHka=r_uHl3Y}HREcgKv1{o{5B49(eMJ$N$g;mI2 z)SOQ4QE^jRBy;!8-M2D(Mm1B`9IR87V1)`6HU+9MP?J?G$IzBkky5EUKzNt zT1J>3v{<*Q#D^C=M%&*2%Cu*(-dK~y{Z&0gyEM`b1orFH69Akj@R7~x@f0GJN@Z1Xns*B1+X%Wt90uM!Az!Vf_^K!}UDGuz zXo3dCEg9Hgrin$_Vzyv2TU9#1pHX`<@=Z>zfL+!ML$Zj9P3Rs#U{FbuiYn-XK?EHl zT8i0$eXvbF7CJ##Lj*Yu&%+h~jHnyZh8v@qAe;^w$^00%sk!o6d{2;Q0pTc25M^d! z*%YB~5k|2oJqQ|U8AOy6QL#0%)B6@5`@=h3;8cck{8H&CzpXS>db;!~|5E8!{AlSK zUjwI7%$!PdHTIyCIu-COx9^+nwz)3dc*{PrvW<}bY$Hf+^G=%Ea!GfV57w84Tup`b zX4$kkQT`-S^gPl%&Mo}<5?b{Be(s44g}#v-UnzZK-Sho#6#rD**f#>kYiFM@cev0y zIlg~x??6wUX`%#Y!-jm)_hSe7?a67&|a)E=RYiZ;{_s@^?ba7X5YX`V10L$+lQ<+D$p-M=-TAJ6Ak;dZaLf#!i)J!DY?J~AsgP@7EtT)&FqsQ`Lw*zo=ItQrt6 zDx-1Y`Y{}>4Q_;L0XWULQA6;5n$3a~hN#MX77`CRZtO#FL}EnzSwyO=g2q_2D*V9z#_~tsYfILPn+?62-$v#;gjDsWIqpXvT#`71doI63&o@k%Ytn zivSB0c`jwi>xZ~U7Y@C(c^9IMI%HjQPr>Io$*L?)Glj&(ikhxPkn{uunjS{`L&m1z z163RFod!ic`NH42Hx(=vhAxX1zWdg|K|`oQtO#bE7}W#FBS3?#879390Kf^L6;K*@ zE`-P)UO}U?c9_iv!)T9N%hHvT?;Pf~FYNkpU*TeI)P+KasxPd>%Q9Cw+@C% zu6Q|Dyqs1{QGf0;#jIlXpWXKCgIi&Q%jZ{)N4K7iI=!p;_$dnR6FC1r1(VSdhOcz( zo|VnK%7&e=?o;J}PX7H~pyFQN*AJxMkX0D&r zQVZ%+h?3As#V*Wda+qE~1T#@%VRg2GmTQy0sW=nwdtF-=lArEQo;%;Qy}yF?xjrZ= m$eNM->wMSFWc$lqTa)K6bd{6(VQzEMc)6=mY)|B=LH;l6D}7o3 delta 310 zcmW-dKS)AR9EI=C_qt)%ch3zZk_dcg;1D4s(I6x)xdbjv7D#haLp0W#3R-;T@Y)P& zmw3a=A%doc{tz@cR8m4MMzz=wM3;uMeCNOi$2nAvqujB94r44=i_cN#q<&meH0i2xNRazA1h8W`iQ8m1S1ey26TV zX*_B9*~iOJ#6*E1z&;A7_`~b7Fq6gt)o-8T{_4j;S#c3%n37A@UgT7~is+H8L=Y{} zr>Qsu)510~TG)i}?ew=aQ^q2B@e(Dy={363qXfglzoDWX2|WV5-!0wGZ_}B{(PhDq yL-(i^2fc2UY>mb>a_L>x(<