Compare commits

..

7 Commits

6 changed files with 712 additions and 2 deletions

Binary file not shown.

View File

@ -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<GeneratedPlanContent> {
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<GeneratedPlanContent> {
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<GeneratedPlanContent> {
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 },
);
}
}

View File

@ -18,6 +18,16 @@ export interface Recommendation {
updatedAt: string; updatedAt: string;
} }
interface RecommendationMeta {
usedFallbackPlan?: boolean;
}
interface RecommendationApiEnvelope {
success?: boolean;
data?: Recommendation;
meta?: RecommendationMeta;
}
export interface GenerateRecommendationRequest { export interface GenerateRecommendationRequest {
userId: string; userId: string;
useExternalModel?: boolean; useExternalModel?: boolean;
@ -109,3 +119,36 @@ export async function approveRecommendation(
throw error; throw error;
} }
} }
/**
* Generate AI recommendation for the authenticated client user
*/
export async function generateSelfRecommendation(
token: string | null,
): Promise<Recommendation> {
try {
const response = await apiClient.post<RecommendationApiEnvelope>(
`${API_ENDPOINTS.RECOMMENDATIONS}/generate-self`,
{},
withAuth(token),
);
return parseApiData<Recommendation>(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;
}
}

View File

@ -22,6 +22,7 @@ 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 { useMembership } from "../../hooks/useMembership";
import type { FitnessGoal, CreateGoalData } from "../../services/fitnessGoals"; import type { FitnessGoal, CreateGoalData } from "../../services/fitnessGoals";
import { useStatistics } from "../../contexts/StatisticsContext"; import { useStatistics } from "../../contexts/StatisticsContext";
import { useFitnessGoals } from "../../contexts/FitnessGoalsContext"; import { useFitnessGoals } from "../../contexts/FitnessGoalsContext";
@ -45,15 +46,24 @@ export default function GoalsScreen() {
deleteGoal, deleteGoal,
clearCache: clearGoalsCache, clearCache: clearGoalsCache,
} = useFitnessGoals(); } = useFitnessGoals();
const { clearCache: clearRecommendationsCache } = useRecommendations(); const {
recommendations,
clearCache: clearRecommendationsCache,
refetchRecommendations,
generateSelfPlan,
} = useRecommendations();
const { membershipType, features } = useMembership();
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 [isGeneratingPlan, setIsGeneratingPlan] = useState(false);
const [showFullPlan, setShowFullPlan] = useState(false);
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
await refetchGoals(); await refetchGoals();
await refetchRecommendations();
await refetchStatistics(); await refetchStatistics();
}, [refetchGoals, refetchStatistics]); }, [refetchGoals, refetchRecommendations, refetchStatistics]);
const clearClerkCache = async () => { const clearClerkCache = async () => {
Alert.alert( Alert.alert(
@ -136,6 +146,76 @@ export default function GoalsScreen() {
) )
: 0; : 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 ( return (
<View style={[styles.container, { backgroundColor: colors.background }]}> <View style={[styles.container, { backgroundColor: colors.background }]}>
<ScrollView <ScrollView
@ -352,6 +432,89 @@ export default function GoalsScreen() {
{/* Active Goals */} {/* Active Goals */}
<View style={styles.section}> <View style={styles.section}>
<MinimalCard variant="elevated" style={styles.aiPlanCard}>
<View style={styles.aiPlanHeader}>
<View style={styles.aiPlanHeaderLeft}>
<View
style={[
styles.aiPlanIcon,
{ backgroundColor: `${colors.primary}15` },
]}
>
<Ionicons name="sparkles" size={20} color={colors.primary} />
</View>
<View>
<Text style={[typography.h3, { color: colors.textPrimary }]}>
AI Activity Plan
</Text>
<Text
style={[
typography.caption,
{ color: colors.textTertiary, marginTop: 2 },
]}
>
{latestApprovedRecommendation
? `Generated ${new Date(latestApprovedRecommendation.generatedAt).toLocaleDateString()}`
: "Generate a plan aligned to your fitness profile"}
</Text>
</View>
</View>
<Badge
variant={membershipType === "basic" ? "neutral" : "success"}
label={membershipType.toUpperCase()}
size="sm"
/>
</View>
{latestApprovedRecommendation ? (
<>
<Text
style={[
typography.body,
{ color: colors.textSecondary, marginTop: 14 },
]}
numberOfLines={showFullPlan ? undefined : 4}
>
{latestApprovedRecommendation.activityPlan}
</Text>
<TouchableOpacity
onPress={() => setShowFullPlan((prev) => !prev)}
style={styles.showPlanButton}
>
<Text style={[typography.caption, { color: colors.primary }]}>
{showFullPlan ? "Show less" : "View full plan"}
</Text>
</TouchableOpacity>
</>
) : (
<Text
style={[
typography.body,
{ color: colors.textSecondary, marginTop: 14 },
]}
>
No AI activity plan yet. Generate one to get personalized
guidance.
</Text>
)}
<MinimalButton
title={
membershipType === "basic"
? "Upgrade to Generate Plan"
: latestApprovedRecommendation
? "Regenerate Plan"
: "Generate Plan"
}
onPress={handleGenerateAiPlan}
disabled={isGeneratingPlan}
loading={isGeneratingPlan}
size="md"
fullWidth
style={{ marginTop: 14 }}
/>
</MinimalCard>
<SectionHeader <SectionHeader
title={`Active Goals (${activeGoals.length})`} title={`Active Goals (${activeGoals.length})`}
subtitle="Keep pushing forward!" subtitle="Keep pushing forward!"
@ -386,6 +549,7 @@ export default function GoalsScreen() {
<GoalProgressCard <GoalProgressCard
key={goal.id} key={goal.id}
goal={goal} goal={goal}
aiAligned={isGoalAiAligned(goal)}
onComplete={() => handleCompleteGoal(goal)} onComplete={() => handleCompleteGoal(goal)}
onDelete={() => handleDeleteGoal(goal.id)} onDelete={() => handleDeleteGoal(goal.id)}
/> />
@ -509,6 +673,33 @@ const styles = StyleSheet.create({
borderTopWidth: 1, borderTopWidth: 1,
borderTopColor: "rgba(0,0,0,0.05)", 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: { chartSection: {
marginBottom: 20, marginBottom: 20,
}, },

View File

@ -20,6 +20,7 @@ interface GoalProgressCardProps {
onPress?: () => void; onPress?: () => void;
onComplete?: () => void; onComplete?: () => void;
onDelete?: () => void; onDelete?: () => void;
aiAligned?: boolean;
} }
export function GoalProgressCard({ export function GoalProgressCard({
@ -27,6 +28,7 @@ export function GoalProgressCard({
onPress, onPress,
onComplete, onComplete,
onDelete, onDelete,
aiAligned = false,
}: GoalProgressCardProps) { }: GoalProgressCardProps) {
const { colors, typography } = useTheme(); const { colors, typography } = useTheme();
const isCompleted = goal.status === "completed"; const isCompleted = goal.status === "completed";
@ -277,6 +279,10 @@ export function GoalProgressCard({
</Text> </Text>
)} )}
{aiAligned && !isCompleted && (
<Badge variant="info" label="AI-ALIGNED" size="sm" />
)}
{isCompleted && goal.completedDate && ( {isCompleted && goal.completedDate && (
<Text style={[typography.caption, { color: colors.success }]}> <Text style={[typography.caption, { color: colors.success }]}>
Completed {new Date(goal.completedDate).toLocaleDateString()} Completed {new Date(goal.completedDate).toLocaleDateString()}

View File

@ -10,6 +10,7 @@ import { useUser, useAuth } from "@clerk/clerk-expo";
import { import {
getRecommendations, getRecommendations,
generateRecommendation, generateRecommendation,
generateSelfRecommendation,
type Recommendation, type Recommendation,
type GenerateRecommendationRequest, type GenerateRecommendationRequest,
} from "../api/recommendations"; } from "../api/recommendations";
@ -23,6 +24,7 @@ interface RecommendationsContextValue {
generateNewRecommendation: ( generateNewRecommendation: (
data: GenerateRecommendationRequest, data: GenerateRecommendationRequest,
) => Promise<Recommendation>; ) => Promise<Recommendation>;
generateSelfPlan: () => Promise<Recommendation>;
clearCache: () => void; clearCache: () => void;
} }
@ -107,6 +109,18 @@ export function RecommendationsProvider({
[user?.id, getToken], [user?.id, getToken],
); );
const generateSelfPlan = useCallback(async (): Promise<Recommendation> => {
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(() => { const clearCache = useCallback(() => {
setRecommendations([]); setRecommendations([]);
setLoading(false); setLoading(false);
@ -133,6 +147,7 @@ export function RecommendationsProvider({
error, error,
refetchRecommendations, refetchRecommendations,
generateNewRecommendation, generateNewRecommendation,
generateSelfPlan,
clearCache, clearCache,
}} }}
> >