From 803c205994ba76a6e0f4aa74dd259daf225231de Mon Sep 17 00:00:00 2001 From: echo Date: Wed, 26 Nov 2025 01:30:15 +0100 Subject: [PATCH] context and goals definition added --- apps/admin/data/fitai.db | Bin 135168 -> 139264 bytes apps/admin/scripts/create-fitness-goals.js | 2 +- apps/mobile/package-lock.json | 24 + apps/mobile/package.json | 1 + apps/mobile/src/app/(tabs)/goals.tsx | 362 ++++++++------- .../mobile/src/app/(tabs)/recommendations.tsx | 28 ++ .../src/components/GoalCreationModal.tsx | 414 ++++++++++++++++++ .../src/components/GoalProgressCard.tsx | 251 +++++++++++ apps/mobile/src/config/api.ts | 8 + apps/mobile/src/services/fitnessGoals.ts | 153 +++++++ 10 files changed, 1073 insertions(+), 170 deletions(-) create mode 100644 apps/mobile/src/components/GoalCreationModal.tsx create mode 100644 apps/mobile/src/components/GoalProgressCard.tsx create mode 100644 apps/mobile/src/services/fitnessGoals.ts diff --git a/apps/admin/data/fitai.db b/apps/admin/data/fitai.db index 8ead25bdc4782d121181fd47e5467a38303c9f36..9090b8d2af20d1220ac7a5f5d69c4cabd2a210b3 100644 GIT binary patch delta 1249 zcma)5Uufh+7|(RubK5nUR=wK2DjjQK+p}5B*xc4C5f#8Bz@4ZvImQB4}wp^!GUP0(1H}9i1(m<=u74F%dLlU2pTe(%=hQ_ z{l4GKtldbieU-d6Id;Nv+%${_kF}l6p_4D({A_9>iT7jcG(N+p_&0nPALAo@i1)kC zjNTaN-kN@j9|HH7uyeSOc>?p-Il&UH3FmQ2I1;|(zvi!Z$L05VaW-)k<*PZFYB@!$ z*6W&h(aaac;zdOfP52m^t`@3V5yyei&j~cs{iS?P74omja!FN7xuT@#YWELsXzd)r z^BkTB0tDyZTSSjnDqJSbA&x7TxpXoOlUdE1ieAfUik7eGvaD;ST&MYBRjq59QpoT8 z*dd)&YFiEV1~Yt%GLdNHE$d}fRy7eQsr|Zzl9rQlMP;dPS4FjOpHHDA%aSb1OGb^^ zE3`@LcG&#)T=lQqhcQ*ObQ2AZLNf;(KIo49`YpcnY@#y!U(`oHZOrd+!ad;!;imAB zuq<2_2!F=!^(F|q#Ybl%nn5@s5}N5vN$7a@fET3rxGzUc5#irCcnSPY!FUYDD8&7Qg+ziI=3$9a$6V?d7#ybZTjLmC ztyCu0b01hAo`1SruKb^~67OR4Ziq6S*Ss*}Q{tH>VIDC^z^GAg!({l3crIysp-(K& zaALRP1@!)IAFX{%SE%16%iwltn^dV~y8&6XStACqUCW^{R!bs_wp*hyPF!a>3|K$d zg_tbqIFw}qVtTbOh|5(&+o^+x)I2w^0l;OXVN`7v5Y{jl6mT5UpiV0WBrV^wd>RCO z*0$4@$SZK9npNs&NzgPPwr+r#P}B5$z;K|sxRJoNT-&s3#th&)o@D$6KX!QiZ;w^$j$iQb|Ao-ZP0FeZGO_7XLOVGI2uJ zq5)}99|%|yxnjF^(CFi9q-nb$12-f)@wI|xe9CH#SOs7Wq-tKQb+jFzm!gwRq^GmQ zaOOy?HhZ*?N`6lp*@^kY&;@io4c(a5-6YsW-0PF$~3BpX91p`v} zr&sJ}?2zCB^7(jwGw}W6yUDkOub(fA&y`Ps_xE-w1;#x=52.0.0", + "react": "*", + "react-native": "*", + "react-native-windows": "*" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + }, + "react-native-windows": { + "optional": true + } + } + }, "node_modules/@react-native-picker/picker": { "version": "2.11.1", "resolved": "https://registry.npmjs.org/@react-native-picker/picker/-/picker-2.11.1.tgz", diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 84742c2..c5de955 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -17,6 +17,7 @@ "@clerk/clerk-expo": "^2.18.3", "@expo/vector-icons": "^15.0.0", "@hookform/resolvers": "^3.3.0", + "@react-native-community/datetimepicker": "8.4.4", "@react-native-picker/picker": "2.11.1", "@tanstack/react-query": "^5.0.0", "ajv": "^8.12.0", diff --git a/apps/mobile/src/app/(tabs)/goals.tsx b/apps/mobile/src/app/(tabs)/goals.tsx index d50737b..ed7872a 100644 --- a/apps/mobile/src/app/(tabs)/goals.tsx +++ b/apps/mobile/src/app/(tabs)/goals.tsx @@ -11,44 +11,26 @@ import { } from "react-native"; import { useAuth } from "@clerk/clerk-expo"; import { Ionicons } from "@expo/vector-icons"; -import { API_BASE_URL, API_ENDPOINTS } from "../../config/api"; - -interface Recommendation { - id: string; - userId: string; - type: "short_term" | "medium_term" | "long_term"; - content: string; - status: "pending" | "completed"; - createdAt: string; -} +import { fitnessGoalsService, type FitnessGoal, type CreateGoalData } from "../../services/fitnessGoals"; +import { GoalProgressCard } from "../../components/GoalProgressCard"; +import { GoalCreationModal } from "../../components/GoalCreationModal"; export default function GoalsScreen() { const { userId, getToken } = useAuth(); - const [recommendations, setRecommendations] = useState([]); + const [goals, setGoals] = useState([]); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); + const [showCreateModal, setShowCreateModal] = useState(false); - const fetchRecommendations = async () => { + const fetchGoals = async () => { if (!userId) return; try { const token = await getToken(); - const response = await fetch( - `${API_BASE_URL}${API_ENDPOINTS.RECOMMENDATIONS}?userId=${userId}`, - { - headers: { - Authorization: `Bearer ${token}`, - }, - } - ); - - if (response.ok) { - const data = await response.json(); - setRecommendations(data); - } else { - console.error("Failed to fetch recommendations"); - } + const data = await fitnessGoalsService.getGoals(userId, token); + setGoals(data); } catch (error) { - console.error("Error fetching recommendations:", error); + console.error("Error fetching fitness goals:", error); + Alert.alert("Error", "Failed to load goals. Please try again."); } finally { setLoading(false); setRefreshing(false); @@ -56,99 +38,53 @@ export default function GoalsScreen() { }; useEffect(() => { - fetchRecommendations(); + fetchGoals(); }, [userId]); const onRefresh = () => { setRefreshing(true); - fetchRecommendations(); + fetchGoals(); }; - const toggleStatus = async (id: string, currentStatus: string) => { - const newStatus = currentStatus === "pending" ? "completed" : "pending"; - - // Optimistic update - setRecommendations(prev => - prev.map(r => r.id === id ? { ...r, status: newStatus } : r) - ); - + const handleCreateGoal = async (goalData: CreateGoalData) => { try { const token = await getToken(); - const response = await fetch(`${API_BASE_URL}${API_ENDPOINTS.RECOMMENDATIONS}`, { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ - id, - status: newStatus, - }), - }); - - if (!response.ok) { - // Revert on failure - setRecommendations(prev => - prev.map(r => r.id === id ? { ...r, status: currentStatus as "pending" | "completed" } : r) - ); - Alert.alert("Error", "Failed to update status"); - } + const newGoal = await fitnessGoalsService.createGoal(goalData, token); + setGoals(prev => [newGoal, ...prev]); + Alert.alert("Success", "Goal created successfully!"); } catch (error) { - console.error(error); - // Revert on error - setRecommendations(prev => - prev.map(r => r.id === id ? { ...r, status: currentStatus as "pending" | "completed" } : r) - ); - Alert.alert("Error", "Failed to update status"); + console.error("Error creating goal:", error); + throw error; } }; - const renderSection = ( - title: string, - type: "short_term" | "medium_term" | "long_term" - ) => { - const items = recommendations.filter((r) => r.type === type); - - return ( - - {title} - {items.length === 0 ? ( - No goals set yet. - ) : ( - items.map((rec) => ( - toggleStatus(rec.id, rec.status)} - > - - {rec.status === "completed" && ( - - )} - - - - {rec.content} - - - {new Date(rec.createdAt).toLocaleDateString()} - - - - )) - )} - - ); + const handleCompleteGoal = async (goalId: string) => { + try { + const token = await getToken(); + const updatedGoal = await fitnessGoalsService.completeGoal(goalId, token); + setGoals(prev => prev.map(g => g.id === goalId ? updatedGoal : g)); + Alert.alert("Success", "Goal completed! 🎉"); + } catch (error) { + console.error("Error completing goal:", error); + Alert.alert("Error", "Failed to complete goal. Please try again."); + } }; + const handleDeleteGoal = async (goalId: string) => { + try { + const token = await getToken(); + await fitnessGoalsService.deleteGoal(goalId, token); + setGoals(prev => prev.filter(g => g.id !== goalId)); + Alert.alert("Success", "Goal deleted"); + } catch (error) { + console.error("Error deleting goal:", error); + Alert.alert("Error", "Failed to delete goal. Please try again."); + } + }; + + const activeGoals = goals.filter(g => g.status === 'active'); + const completedGoals = goals.filter(g => g.status === 'completed'); + if (loading && !refreshing) { return ( @@ -158,25 +94,105 @@ export default function GoalsScreen() { } return ( - - } - > - - My Goals - - Track your fitness journey progress - - + + + } + > + + + My Fitness Goals + + Track your fitness journey progress + + + - {renderSection("Short Term Goals", "short_term")} - {renderSection("Medium Term Goals", "medium_term")} - {renderSection("Long Term Goals", "long_term")} + {/* Stats Summary */} + {goals.length > 0 && ( + + + {activeGoals.length} + Active + + + {completedGoals.length} + Completed + + + + {activeGoals.length > 0 + ? Math.round( + activeGoals.reduce((sum, g) => sum + g.progress, 0) / + activeGoals.length + ) + : 0}% + + Avg Progress + + + )} - - + {/* Active Goals */} + + + Active Goals ({activeGoals.length}) + + {activeGoals.length === 0 ? ( + + + No active goals yet + + Tap the + button to create your first goal + + + ) : ( + activeGoals.map((goal) => ( + handleCompleteGoal(goal.id)} + onDelete={() => handleDeleteGoal(goal.id)} + /> + )) + )} + + + {/* Completed Goals */} + {completedGoals.length > 0 && ( + + + Completed Goals ({completedGoals.length}) + + {completedGoals.map((goal) => ( + handleDeleteGoal(goal.id)} + /> + ))} + + )} + + + + + {/* Floating Action Button */} + setShowCreateModal(true)} + > + + + + {/* Create Goal Modal */} + setShowCreateModal(false)} + onSubmit={handleCreateGoal} + /> + ); } @@ -205,6 +221,34 @@ const styles = StyleSheet.create({ color: "#6b7280", marginTop: 4, }, + statsContainer: { + flexDirection: "row", + padding: 16, + gap: 12, + }, + statCard: { + flex: 1, + backgroundColor: "#fff", + padding: 16, + borderRadius: 12, + alignItems: "center", + shadowColor: "#000", + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.05, + shadowRadius: 2, + elevation: 2, + }, + statValue: { + fontSize: 24, + fontWeight: "bold", + color: "#2563eb", + marginBottom: 4, + }, + statLabel: { + fontSize: 12, + color: "#6b7280", + fontWeight: "500", + }, section: { padding: 20, paddingTop: 10, @@ -215,58 +259,38 @@ const styles = StyleSheet.create({ color: "#374151", marginBottom: 12, }, - emptyText: { - fontStyle: "italic", - color: "#9ca3af", - }, - card: { - backgroundColor: "#fff", - padding: 16, - borderRadius: 12, - marginBottom: 12, - flexDirection: "row", - alignItems: "flex-start", - shadowColor: "#000", - shadowOffset: { width: 0, height: 1 }, - shadowOpacity: 0.05, - shadowRadius: 2, - elevation: 2, - }, - cardCompleted: { - backgroundColor: "#f0fdf4", // light green - borderColor: "#bbf7d0", - borderWidth: 1, - }, - checkbox: { - width: 24, - height: 24, - borderRadius: 6, - borderWidth: 2, - borderColor: "#d1d5db", - marginRight: 12, - marginTop: 2, - justifyContent: "center", + emptyState: { alignItems: "center", - backgroundColor: "#fff", + paddingVertical: 40, }, - cardContent: { - flex: 1, - }, - cardText: { + emptyText: { fontSize: 16, - color: "#1f2937", - lineHeight: 24, + fontWeight: "500", + color: "#6b7280", + marginTop: 12, }, - cardTextCompleted: { - textDecorationLine: "line-through", - color: "#9ca3af", - }, - dateText: { - fontSize: 12, + emptySubtext: { + fontSize: 14, color: "#9ca3af", marginTop: 4, }, footer: { - height: 40, + height: 100, + }, + fab: { + position: "absolute", + right: 20, + bottom: 20, + width: 56, + height: 56, + borderRadius: 28, + backgroundColor: "#2563eb", + justifyContent: "center", + alignItems: "center", + shadowColor: "#000", + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.3, + shadowRadius: 8, + elevation: 8, }, }); diff --git a/apps/mobile/src/app/(tabs)/recommendations.tsx b/apps/mobile/src/app/(tabs)/recommendations.tsx index ecedc89..d3f668f 100644 --- a/apps/mobile/src/app/(tabs)/recommendations.tsx +++ b/apps/mobile/src/app/(tabs)/recommendations.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; import { View, Text, FlatList, ActivityIndicator, StyleSheet, RefreshControl } from "react-native"; import { useAuth } from "@clerk/clerk-expo"; +import { Ionicons } from "@expo/vector-icons"; import { API_BASE_URL, API_ENDPOINTS } from "../../config/api"; interface Recommendation { @@ -77,6 +78,15 @@ export default function RecommendationsScreen() { return ( AI Recommendations + + {/* AI Context Info Banner */} + + + + Personalized based on your active fitness goals and progress + + + item.id} @@ -129,6 +139,24 @@ const styles = StyleSheet.create({ paddingBottom: 12, color: '#1a1a1a', }, + infoBanner: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: '#eff6ff', + marginHorizontal: 16, + marginBottom: 12, + padding: 12, + borderRadius: 8, + borderLeftWidth: 3, + borderLeftColor: '#2563eb', + gap: 8, + }, + infoBannerText: { + flex: 1, + fontSize: 13, + color: '#1e40af', + lineHeight: 18, + }, centered: { flex: 1, justifyContent: 'center', diff --git a/apps/mobile/src/components/GoalCreationModal.tsx b/apps/mobile/src/components/GoalCreationModal.tsx new file mode 100644 index 0000000..d078578 --- /dev/null +++ b/apps/mobile/src/components/GoalCreationModal.tsx @@ -0,0 +1,414 @@ +import React, { useState } from 'react'; +import { + View, + Text, + StyleSheet, + Modal, + TextInput, + TouchableOpacity, + ScrollView, + Platform, +} from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import DateTimePicker from '@react-native-community/datetimepicker'; +import type { CreateGoalData } from '../services/fitnessGoals'; + +interface GoalCreationModalProps { + visible: boolean; + onClose: () => void; + onSubmit: (goalData: CreateGoalData) => Promise; +} + +const GOAL_TYPES = [ + { value: 'weight_target', label: 'Weight Target' }, + { value: 'strength_milestone', label: 'Strength Milestone' }, + { value: 'endurance_target', label: 'Endurance Target' }, + { value: 'flexibility_goal', label: 'Flexibility Goal' }, + { value: 'habit_building', label: 'Habit Building' }, + { value: 'custom', label: 'Custom Goal' }, +] as const; + +const PRIORITIES = [ + { value: 'low', label: 'Low', color: '#10b981' }, + { value: 'medium', label: 'Medium', color: '#f59e0b' }, + { value: 'high', label: 'High', color: '#ef4444' }, +] as const; + +export function GoalCreationModal({ visible, onClose, onSubmit }: GoalCreationModalProps) { + const [goalType, setGoalType] = useState('weight_target'); + const [title, setTitle] = useState(''); + const [description, setDescription] = useState(''); + const [targetValue, setTargetValue] = useState(''); + const [currentValue, setCurrentValue] = useState(''); + const [unit, setUnit] = useState(''); + const [priority, setPriority] = useState<'low' | 'medium' | 'high'>('medium'); + const [targetDate, setTargetDate] = useState(); + const [showDatePicker, setShowDatePicker] = useState(false); + const [submitting, setSubmitting] = useState(false); + + const resetForm = () => { + setGoalType('weight_target'); + setTitle(''); + setDescription(''); + setTargetValue(''); + setCurrentValue(''); + setUnit(''); + setPriority('medium'); + setTargetDate(undefined); + }; + + const handleSubmit = async () => { + if (!title.trim()) { + alert('Please enter a goal title'); + return; + } + + setSubmitting(true); + try { + const goalData: CreateGoalData = { + goalType, + title: title.trim(), + description: description.trim() || undefined, + targetValue: targetValue ? parseFloat(targetValue) : undefined, + currentValue: currentValue ? parseFloat(currentValue) : undefined, + unit: unit.trim() || undefined, + targetDate: targetDate?.toISOString(), + priority, + }; + + await onSubmit(goalData); + resetForm(); + onClose(); + } catch (error) { + console.error('Error creating goal:', error); + alert('Failed to create goal. Please try again.'); + } finally { + setSubmitting(false); + } + }; + + const handleClose = () => { + resetForm(); + onClose(); + }; + + return ( + + + + Create Fitness Goal + + + + + + + {/* Goal Type */} + + Goal Type * + + {GOAL_TYPES.map((type) => ( + setGoalType(type.value)} + > + + {type.label} + + + ))} + + + + {/* Title */} + + Title * + + + + {/* Description */} + + Description + + + + {/* Target Value & Unit */} + + + Target Value + + + + Unit + + + + + {/* Current Value */} + + Current Value + + + + {/* Target Date */} + + Target Date + setShowDatePicker(true)} + > + + {targetDate ? targetDate.toLocaleDateString() : 'Select target date'} + + + + + + {showDatePicker && ( + { + setShowDatePicker(Platform.OS === 'ios'); + if (selectedDate) { + setTargetDate(selectedDate); + } + }} + minimumDate={new Date()} + /> + )} + + {/* Priority */} + + Priority + + {PRIORITIES.map((p) => ( + setPriority(p.value)} + > + + {p.label} + + + ))} + + + + + + + + {submitting ? 'Creating...' : 'Create Goal'} + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#f9fafb', + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + padding: 20, + paddingTop: Platform.OS === 'ios' ? 60 : 20, + backgroundColor: '#fff', + borderBottomWidth: 1, + borderBottomColor: '#e5e7eb', + }, + headerTitle: { + fontSize: 20, + fontWeight: '600', + color: '#111827', + }, + closeButton: { + padding: 4, + }, + content: { + flex: 1, + padding: 20, + }, + field: { + marginBottom: 20, + }, + label: { + fontSize: 14, + fontWeight: '600', + color: '#374151', + marginBottom: 8, + }, + input: { + backgroundColor: '#fff', + borderWidth: 1, + borderColor: '#d1d5db', + borderRadius: 8, + padding: 12, + fontSize: 16, + color: '#111827', + }, + textArea: { + height: 80, + textAlignVertical: 'top', + }, + row: { + flexDirection: 'row', + gap: 12, + }, + flex1: { + flex: 1, + }, + typeScroll: { + flexGrow: 0, + }, + typeButton: { + paddingHorizontal: 16, + paddingVertical: 8, + borderRadius: 8, + borderWidth: 1, + borderColor: '#d1d5db', + backgroundColor: '#fff', + marginRight: 8, + }, + typeButtonActive: { + backgroundColor: '#2563eb', + borderColor: '#2563eb', + }, + typeButtonText: { + fontSize: 14, + color: '#374151', + }, + typeButtonTextActive: { + color: '#fff', + fontWeight: '600', + }, + dateButton: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + backgroundColor: '#fff', + borderWidth: 1, + borderColor: '#d1d5db', + borderRadius: 8, + padding: 12, + }, + dateText: { + fontSize: 16, + color: '#111827', + }, + datePlaceholder: { + fontSize: 16, + color: '#9ca3af', + }, + priorityContainer: { + flexDirection: 'row', + gap: 12, + }, + priorityButton: { + flex: 1, + paddingVertical: 12, + borderRadius: 8, + borderWidth: 1, + borderColor: '#d1d5db', + backgroundColor: '#fff', + alignItems: 'center', + }, + priorityButtonText: { + fontSize: 14, + color: '#374151', + fontWeight: '500', + }, + priorityButtonTextActive: { + color: '#fff', + fontWeight: '600', + }, + footer: { + padding: 20, + paddingBottom: Platform.OS === 'ios' ? 40 : 20, + backgroundColor: '#fff', + borderTopWidth: 1, + borderTopColor: '#e5e7eb', + }, + submitButton: { + backgroundColor: '#2563eb', + borderRadius: 8, + padding: 16, + alignItems: 'center', + }, + submitButtonDisabled: { + opacity: 0.6, + }, + submitButtonText: { + fontSize: 16, + fontWeight: '600', + color: '#fff', + }, +}); diff --git a/apps/mobile/src/components/GoalProgressCard.tsx b/apps/mobile/src/components/GoalProgressCard.tsx new file mode 100644 index 0000000..19e671c --- /dev/null +++ b/apps/mobile/src/components/GoalProgressCard.tsx @@ -0,0 +1,251 @@ +import React from 'react'; +import { View, Text, StyleSheet, TouchableOpacity, Alert } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import type { FitnessGoal } from '../services/fitnessGoals'; + +interface GoalProgressCardProps { + goal: FitnessGoal; + onPress?: () => void; + onComplete?: () => void; + onDelete?: () => void; +} + +export function GoalProgressCard({ goal, onPress, onComplete, onDelete }: GoalProgressCardProps) { + const isCompleted = goal.status === 'completed'; + const progress = goal.progress || 0; + + // Calculate days remaining + const daysRemaining = goal.targetDate + ? Math.ceil((new Date(goal.targetDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24)) + : null; + + const getGoalTypeIcon = (type: string) => { + switch (type) { + case 'weight_target': return 'scale-outline'; + case 'strength_milestone': return 'barbell-outline'; + case 'endurance_target': return 'bicycle-outline'; + case 'flexibility_goal': return 'body-outline'; + case 'habit_building': return 'calendar-outline'; + default: return 'flag-outline'; + } + }; + + const getPriorityColor = (priority: string) => { + switch (priority) { + case 'high': return '#ef4444'; + case 'medium': return '#f59e0b'; + case 'low': return '#10b981'; + default: return '#6b7280'; + } + }; + + const handleDelete = () => { + Alert.alert( + 'Delete Goal', + 'Are you sure you want to delete this goal?', + [ + { text: 'Cancel', style: 'cancel' }, + { text: 'Delete', style: 'destructive', onPress: onDelete }, + ] + ); + }; + + return ( + + + + + + + {goal.title} + + {goal.description && ( + + {goal.description} + + )} + + + + + {!isCompleted && onComplete && ( + + + + )} + {onDelete && ( + + + + )} + + + + {goal.targetValue && ( + + + + {goal.currentValue || 0} / {goal.targetValue} {goal.unit || ''} + + {progress.toFixed(0)}% + + + + + + + )} + + + + {goal.priority.toUpperCase()} + + + {daysRemaining !== null && !isCompleted && ( + + {daysRemaining < 0 + ? `${Math.abs(daysRemaining)} days overdue` + : `${daysRemaining} days remaining` + } + + )} + + {isCompleted && goal.completedDate && ( + + Completed {new Date(goal.completedDate).toLocaleDateString()} + + )} + + + ); +} + +const styles = StyleSheet.create({ + card: { + backgroundColor: '#fff', + borderRadius: 12, + padding: 16, + marginBottom: 12, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, + }, + cardCompleted: { + backgroundColor: '#f0fdf4', + borderColor: '#bbf7d0', + borderWidth: 1, + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + marginBottom: 12, + }, + titleRow: { + flexDirection: 'row', + alignItems: 'flex-start', + flex: 1, + }, + titleContainer: { + marginLeft: 12, + flex: 1, + }, + title: { + fontSize: 18, + fontWeight: '600', + color: '#111827', + marginBottom: 4, + }, + titleCompleted: { + color: '#9ca3af', + textDecorationLine: 'line-through', + }, + description: { + fontSize: 14, + color: '#6b7280', + lineHeight: 20, + }, + actions: { + flexDirection: 'row', + gap: 8, + }, + actionButton: { + padding: 4, + }, + progressSection: { + marginBottom: 12, + }, + progressInfo: { + flexDirection: 'row', + justifyContent: 'space-between', + marginBottom: 8, + }, + progressText: { + fontSize: 14, + fontWeight: '500', + color: '#374151', + }, + progressPercentage: { + fontSize: 14, + fontWeight: '600', + color: '#2563eb', + }, + progressBarContainer: { + height: 8, + backgroundColor: '#e5e7eb', + borderRadius: 4, + overflow: 'hidden', + }, + progressBar: { + height: '100%', + backgroundColor: '#2563eb', + borderRadius: 4, + }, + progressBarCompleted: { + backgroundColor: '#10b981', + }, + footer: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + priorityBadge: { + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 4, + }, + priorityText: { + fontSize: 10, + fontWeight: '600', + color: '#fff', + }, + daysRemaining: { + fontSize: 12, + color: '#6b7280', + }, + overdue: { + color: '#ef4444', + fontWeight: '600', + }, + completedDate: { + fontSize: 12, + color: '#10b981', + fontWeight: '500', + }, +}); diff --git a/apps/mobile/src/config/api.ts b/apps/mobile/src/config/api.ts index 0f15f98..addaf0d 100644 --- a/apps/mobile/src/config/api.ts +++ b/apps/mobile/src/config/api.ts @@ -18,4 +18,12 @@ export const API_ENDPOINTS = { HISTORY: '/api/attendance/history', }, RECOMMENDATIONS: '/api/recommendations', + FITNESS_GOALS: { + LIST: '/api/fitness-goals', + CREATE: '/api/fitness-goals', + GET: (id: string) => `/api/fitness-goals/${id}`, + UPDATE: (id: string) => `/api/fitness-goals/${id}`, + DELETE: (id: string) => `/api/fitness-goals/${id}`, + COMPLETE: (id: string) => `/api/fitness-goals/${id}/complete`, + }, } diff --git a/apps/mobile/src/services/fitnessGoals.ts b/apps/mobile/src/services/fitnessGoals.ts new file mode 100644 index 0000000..078d046 --- /dev/null +++ b/apps/mobile/src/services/fitnessGoals.ts @@ -0,0 +1,153 @@ +import { API_BASE_URL, API_ENDPOINTS } from '../config/api'; + +export interface FitnessGoal { + id: string; + userId: string; + fitnessProfileId?: string; + goalType: 'weight_target' | 'strength_milestone' | 'endurance_target' | 'flexibility_goal' | 'habit_building' | 'custom'; + title: string; + description?: string; + targetValue?: number; + currentValue?: number; + unit?: string; + startDate: string; + targetDate?: string; + completedDate?: string; + status: 'active' | 'completed' | 'abandoned' | 'paused'; + progress: number; + priority: 'low' | 'medium' | 'high'; + notes?: string; + createdAt: string; + updatedAt: string; +} + +export interface CreateGoalData { + goalType: FitnessGoal['goalType']; + title: string; + description?: string; + targetValue?: number; + currentValue?: number; + unit?: string; + targetDate?: string; + priority?: FitnessGoal['priority']; + notes?: string; +} + +export class FitnessGoalsService { + private async getAuthHeaders(token: string | null): Promise { + const headers: HeadersInit = { + 'Content-Type': 'application/json', + }; + + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + return headers; + } + + async getGoals(userId: string, token: string | null, status?: string): Promise { + try { + const headers = await this.getAuthHeaders(token); + let url = `${API_BASE_URL}${API_ENDPOINTS.FITNESS_GOALS.LIST}?userId=${userId}`; + + if (status && status !== 'all') { + url += `&status=${status}`; + } + + const response = await fetch(url, { headers }); + + if (!response.ok) { + throw new Error(`Failed to fetch goals: ${response.status}`); + } + + return await response.json(); + } catch (error) { + console.error('Error fetching fitness goals:', error); + throw error; + } + } + + async createGoal(goalData: CreateGoalData, token: string | null): Promise { + try { + const headers = await this.getAuthHeaders(token); + const response = await fetch(`${API_BASE_URL}${API_ENDPOINTS.FITNESS_GOALS.CREATE}`, { + method: 'POST', + headers, + body: JSON.stringify(goalData), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Failed to create goal'); + } + + return await response.json(); + } catch (error) { + console.error('Error creating fitness goal:', error); + throw error; + } + } + + async updateGoal(id: string, updates: Partial, token: string | null): Promise { + try { + const headers = await this.getAuthHeaders(token); + const response = await fetch(`${API_BASE_URL}${API_ENDPOINTS.FITNESS_GOALS.UPDATE(id)}`, { + method: 'PUT', + headers, + body: JSON.stringify(updates), + }); + + if (!response.ok) { + throw new Error('Failed to update goal'); + } + + return await response.json(); + } catch (error) { + console.error('Error updating fitness goal:', error); + throw error; + } + } + + async updateProgress(id: string, currentValue: number, token: string | null): Promise { + return this.updateGoal(id, { currentValue }, token); + } + + async completeGoal(id: string, token: string | null): Promise { + try { + const headers = await this.getAuthHeaders(token); + const response = await fetch(`${API_BASE_URL}${API_ENDPOINTS.FITNESS_GOALS.COMPLETE(id)}`, { + method: 'POST', + headers, + }); + + if (!response.ok) { + throw new Error('Failed to complete goal'); + } + + return await response.json(); + } catch (error) { + console.error('Error completing fitness goal:', error); + throw error; + } + } + + async deleteGoal(id: string, token: string | null): Promise { + try { + const headers = await this.getAuthHeaders(token); + const response = await fetch(`${API_BASE_URL}${API_ENDPOINTS.FITNESS_GOALS.DELETE(id)}`, { + method: 'DELETE', + headers, + }); + + if (!response.ok) { + throw new Error('Failed to delete goal'); + } + } catch (error) { + console.error('Error deleting fitness goal:', error); + throw error; + } + } +} + +export const fitnessGoalsService = new FitnessGoalsService();