add client self-service ai activity plans on goals screen
This commit is contained in:
parent
0825bb3d65
commit
a65b3cac08
190
apps/admin/src/app/api/recommendations/generate-self/route.ts
Normal file
190
apps/admin/src/app/api/recommendations/generate-self/route.ts
Normal file
@ -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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -109,3 +109,26 @@ 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(
|
||||||
|
`${API_ENDPOINTS.RECOMMENDATIONS}/generate-self`,
|
||||||
|
{},
|
||||||
|
withAuth(token),
|
||||||
|
);
|
||||||
|
return parseApiData<Recommendation>(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
if (isAxiosError(error) && 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 { 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,75 @@ 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 (!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 (
|
return (
|
||||||
<View style={[styles.container, { backgroundColor: colors.background }]}>
|
<View style={[styles.container, { backgroundColor: colors.background }]}>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
@ -352,6 +431,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 +548,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 +672,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,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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()}
|
||||||
|
|||||||
@ -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,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user