Compare commits
6 Commits
0825bb3d65
...
178ad3fa90
| Author | SHA1 | Date | |
|---|---|---|---|
| 178ad3fa90 | |||
| ef9f39e564 | |||
| 73218402f6 | |||
| c877577fba | |||
| e119f0923c | |||
| a65b3cac08 |
Binary file not shown.
455
apps/admin/src/app/api/recommendations/generate-self/route.ts
Normal file
455
apps/admin/src/app/api/recommendations/generate-self/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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<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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
<View style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
<ScrollView
|
||||
@ -352,6 +432,89 @@ export default function GoalsScreen() {
|
||||
|
||||
{/* Active Goals */}
|
||||
<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
|
||||
title={`Active Goals (${activeGoals.length})`}
|
||||
subtitle="Keep pushing forward!"
|
||||
@ -386,6 +549,7 @@ export default function GoalsScreen() {
|
||||
<GoalProgressCard
|
||||
key={goal.id}
|
||||
goal={goal}
|
||||
aiAligned={isGoalAiAligned(goal)}
|
||||
onComplete={() => 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,
|
||||
},
|
||||
|
||||
@ -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({
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{aiAligned && !isCompleted && (
|
||||
<Badge variant="info" label="AI-ALIGNED" size="sm" />
|
||||
)}
|
||||
|
||||
{isCompleted && goal.completedDate && (
|
||||
<Text style={[typography.caption, { color: colors.success }]}>
|
||||
Completed {new Date(goal.completedDate).toLocaleDateString()}
|
||||
|
||||
@ -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<Recommendation>;
|
||||
generateSelfPlan: () => Promise<Recommendation>;
|
||||
clearCache: () => void;
|
||||
}
|
||||
|
||||
@ -107,6 +109,18 @@ export function RecommendationsProvider({
|
||||
[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(() => {
|
||||
setRecommendations([]);
|
||||
setLoading(false);
|
||||
@ -133,6 +147,7 @@ export function RecommendationsProvider({
|
||||
error,
|
||||
refetchRecommendations,
|
||||
generateNewRecommendation,
|
||||
generateSelfPlan,
|
||||
clearCache,
|
||||
}}
|
||||
>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user