add client self-service ai activity plans on goals screen

This commit is contained in:
dimitar committed 2026-03-31 18:51:30 +02:00
1 parent 0825bb3d65
commit a65b3cac08
5 files changed
+426 -2

No files matched your search

+23
View File
@@ -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<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;
}
}
+192 -2
View File
@@ -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 (
<View style={[styles.container, { backgroundColor: colors.background }]}>
<ScrollView
@@ -352,6 +431,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 +548,7 @@ export default function GoalsScreen() {
<GoalProgressCard
key={goal.id}
goal={goal}
aiAligned={isGoalAiAligned(goal)}
onComplete={() => 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,
},
@@ -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,
}}
>