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();