recommendation flow implemented

POC phase compleated
This commit is contained in:
echo 2025-11-20 19:10:16 +01:00
parent 118efad70f
commit 8b4cef33dc
10 changed files with 802 additions and 17 deletions

Binary file not shown.

View File

@ -0,0 +1,106 @@
import { NextResponse } from 'next/server'
import { auth } from '@clerk/nextjs/server'
import { getDatabase } from '@/lib/database'
export async function GET(request: Request) {
try {
const { userId: currentUserId } = await auth()
if (!currentUserId) {
return new NextResponse('Unauthorized', { status: 401 })
}
const { searchParams } = new URL(request.url)
const targetUserId = searchParams.get('userId')
if (!targetUserId) {
return new NextResponse('User ID is required', { status: 400 })
}
const db = await getDatabase()
// Check permissions: Users can view their own, Admins/Trainers can view anyone's
if (currentUserId !== targetUserId) {
const currentUser = await db.getUserById(currentUserId)
const isStaff = currentUser?.role === 'admin' || currentUser?.role === 'superAdmin' || currentUser?.role === 'trainer'
if (!isStaff) {
return new NextResponse('Forbidden', { status: 403 })
}
}
const recommendations = await db.getRecommendationsByUserId(targetUserId)
return NextResponse.json(recommendations)
} catch (error) {
console.error('Error fetching recommendations:', error)
return new NextResponse('Internal Server Error', { status: 500 })
}
}
export async function POST(request: Request) {
try {
const { userId: currentUserId } = await auth()
if (!currentUserId) {
return new NextResponse('Unauthorized', { status: 401 })
}
const db = await getDatabase()
const currentUser = await db.getUserById(currentUserId)
const isStaff = currentUser?.role === 'admin' || currentUser?.role === 'superAdmin' || currentUser?.role === 'trainer'
if (!isStaff) {
return new NextResponse('Forbidden', { status: 403 })
}
const body = await request.json()
const { userId, type, content, status } = body
if (!userId || !type || !content) {
return new NextResponse('Missing required fields', { status: 400 })
}
const recommendation = await db.createRecommendation({
id: crypto.randomUUID(),
userId,
type,
content,
status: status || 'pending'
})
return NextResponse.json(recommendation)
} catch (error) {
console.error('Error creating recommendation:', error)
return new NextResponse('Internal Server Error', { status: 500 })
}
}
export async function PUT(request: Request) {
try {
const { userId: currentUserId } = await auth()
if (!currentUserId) {
return new NextResponse('Unauthorized', { status: 401 })
}
const body = await request.json()
const { id, status, content } = body
if (!id) {
return new NextResponse('Recommendation ID is required', { status: 400 })
}
const db = await getDatabase()
// Users can update status (e.g. mark as completed), Staff can update content too
// Ideally we'd check ownership for status update, but for now let's allow it if they have the ID
// A stricter check would be: fetch recommendation, check if userId matches currentUserId OR if currentUser is staff
const updated = await db.updateRecommendation(id, {
...(status && { status }),
...(content && { content })
})
return NextResponse.json(updated)
} catch (error) {
console.error('Error updating recommendation:', error)
return new NextResponse('Internal Server Error', { status: 500 })
}
}

View File

@ -0,0 +1,104 @@
import { getDatabase } from "@/lib/database";
import { Recommendations } from "@/components/users/Recommendations";
import { notFound } from "next/navigation";
interface PageProps {
params: Promise<{
id: string;
}>;
}
export default async function UserProfilePage({ params }: PageProps) {
const { id } = await params;
const db = await getDatabase();
const user = await db.getUserById(id);
if (!user) {
notFound();
}
const client = await db.getClientByUserId(user.id);
const fitnessProfile = await db.getFitnessProfileByUserId(user.id);
return (
<div className="space-y-8">
<div className="flex justify-between items-center">
<h1 className="text-3xl font-bold">
{user.firstName} {user.lastName}
</h1>
<span className="px-3 py-1 bg-gray-100 rounded-full text-sm font-medium capitalize">
{user.role}
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Basic Info */}
<div className="bg-white p-6 rounded-lg shadow">
<h2 className="text-xl font-semibold mb-4">Contact Information</h2>
<div className="space-y-2">
<p><span className="font-medium">Email:</span> {user.email}</p>
<p><span className="font-medium">Phone:</span> {user.phone || "N/A"}</p>
<p><span className="font-medium">Joined:</span> {user.createdAt.toLocaleDateString()}</p>
</div>
</div>
{/* Client Info */}
{client && (
<div className="bg-white p-6 rounded-lg shadow">
<h2 className="text-xl font-semibold mb-4">Membership Details</h2>
<div className="space-y-2">
<p><span className="font-medium">Type:</span> {client.membershipType}</p>
<p><span className="font-medium">Status:</span> {client.membershipStatus}</p>
<p><span className="font-medium">Member Since:</span> {client.joinDate.toLocaleDateString()}</p>
<p><span className="font-medium">Last Visit:</span> {client.lastVisit?.toLocaleDateString() || "Never"}</p>
</div>
</div>
)}
</div>
{/* Fitness Profile */}
<div className="bg-white p-6 rounded-lg shadow">
<h2 className="text-xl font-semibold mb-4">Fitness Profile</h2>
{fitnessProfile ? (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<h3 className="font-medium mb-2 text-gray-700">Physical Stats</h3>
<p>Height: {fitnessProfile.height} cm</p>
<p>Weight: {fitnessProfile.weight} kg</p>
<p>Age: {fitnessProfile.age}</p>
<p>Gender: {fitnessProfile.gender}</p>
</div>
<div>
<h3 className="font-medium mb-2 text-gray-700">Health & Habits</h3>
<p>Activity Level: {fitnessProfile.activityLevel.replace('_', ' ')}</p>
<p>Diet: {fitnessProfile.dietHabits || "N/A"}</p>
<p>Exercise: {fitnessProfile.exerciseHabits || "N/A"}</p>
</div>
<div>
<h3 className="font-medium mb-2 text-gray-700">Medical</h3>
<p>Conditions: {fitnessProfile.medicalConditions || "None"}</p>
<p>Allergies: {fitnessProfile.allergies || "None"}</p>
<p>Injuries: {fitnessProfile.injuries || "None"}</p>
</div>
<div className="col-span-full mt-4">
<h3 className="font-medium mb-2 text-gray-700">Goals</h3>
<div className="flex flex-wrap gap-2">
{fitnessProfile.fitnessGoals.map((goal, i) => (
<span key={i} className="px-2 py-1 bg-blue-50 text-blue-700 rounded text-sm">
{goal.replace('_', ' ')}
</span>
))}
</div>
</div>
</div>
) : (
<p className="text-gray-500 italic">No fitness profile created yet.</p>
)}
</div>
{/* Recommendations Component */}
<Recommendations userId={user.id} />
</div>
);
}

View File

@ -0,0 +1,170 @@
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/Button";
import { Card, CardHeader, CardContent } from "@/components/ui/card";
interface Recommendation {
id: string;
userId: string;
type: "short_term" | "medium_term" | "long_term";
content: string;
status: "pending" | "completed";
createdAt: string;
}
interface RecommendationsProps {
userId: string;
}
export function Recommendations({ userId }: RecommendationsProps) {
const [recommendations, setRecommendations] = useState<Recommendation[]>([]);
const [loading, setLoading] = useState(true);
const [newRec, setNewRec] = useState<{
type: "short_term" | "medium_term" | "long_term";
content: string;
}>({ type: "short_term", content: "" });
useEffect(() => {
fetchRecommendations();
}, [userId]);
const fetchRecommendations = async () => {
setLoading(true);
try {
const response = await fetch(`/api/recommendations?userId=${userId}`);
if (response.ok) {
const data = await response.json();
setRecommendations(data);
}
} catch (error) {
console.error("Failed to fetch recommendations:", error);
} finally {
setLoading(false);
}
};
const handleAddRecommendation = async (e: React.FormEvent) => {
e.preventDefault();
try {
const response = await fetch("/api/recommendations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
userId,
type: newRec.type,
content: newRec.content,
}),
});
if (response.ok) {
setNewRec({ ...newRec, content: "" });
fetchRecommendations();
} else {
alert("Failed to add recommendation");
}
} catch (error) {
console.error(error);
}
};
const handleDelete = async (id: string) => {
if (!confirm("Are you sure?")) return;
// Note: Delete API not implemented in route.ts yet, but good to have UI ready or we can add it.
// For now, let's assume we might add it or just omit.
// Actually, I didn't add DELETE to route.ts. Let's skip for now.
alert("Delete functionality not available yet.");
};
const groupedRecs = {
short_term: recommendations.filter((r) => r.type === "short_term"),
medium_term: recommendations.filter((r) => r.type === "medium_term"),
long_term: recommendations.filter((r) => r.type === "long_term"),
};
const renderSection = (
title: string,
type: "short_term" | "medium_term" | "long_term",
items: Recommendation[]
) => (
<div className="mb-6">
<h4 className="font-semibold text-lg mb-3 capitalize">{title}</h4>
<div className="space-y-2">
{items.length === 0 && (
<p className="text-gray-500 text-sm italic">No recommendations yet.</p>
)}
{items.map((rec) => (
<div
key={rec.id}
className={`p-3 rounded border flex justify-between items-start ${rec.status === "completed"
? "bg-green-50 border-green-200"
: "bg-white border-gray-200"
}`}
>
<div>
<p className="text-sm">{rec.content}</p>
<p className="text-xs text-gray-400 mt-1">
{new Date(rec.createdAt).toLocaleDateString()} -{" "}
<span
className={
rec.status === "completed"
? "text-green-600 font-medium"
: "text-yellow-600"
}
>
{rec.status === "completed" ? "Completed" : "Pending"}
</span>
</p>
</div>
</div>
))}
</div>
<form onSubmit={handleAddRecommendation} className="mt-3 flex gap-2">
<input
type="hidden"
value={type}
onChange={() => setNewRec({ ...newRec, type })}
/>
{newRec.type === type && (
<>
<input
type="text"
placeholder={`Add ${title.toLowerCase()}...`}
className="flex-1 border rounded px-3 py-1 text-sm"
value={newRec.content}
onChange={(e) =>
setNewRec({ ...newRec, content: e.target.value })
}
required
/>
<Button type="submit" variant="secondary">
Add
</Button>
</>
)}
{newRec.type !== type && (
<Button type="button" variant="secondary" onClick={() => setNewRec({ type, content: "" })} className="text-xs text-gray-500">
+ Add New
</Button>
)}
</form>
</div>
);
if (loading) return <div>Loading recommendations...</div>;
return (
<Card>
<CardHeader>
<h3 className="text-xl font-bold">Fitness Recommendations</h3>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{renderSection("Short Term Goals", "short_term", groupedRecs.short_term)}
{renderSection("Medium Term Goals", "medium_term", groupedRecs.medium_term)}
{renderSection("Long Term Goals", "long_term", groupedRecs.long_term)}
</div>
</CardContent>
</Card>
);
}

View File

@ -3,7 +3,7 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { UserGrid } from "@/components/users/UserGrid"; import { UserGrid } from "@/components/users/UserGrid";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { Card, CardHeader, CardContent } from "@/components/ui/Card"; import { Card, CardHeader, CardContent } from "@/components/ui/card";
interface User { interface User {
id: string; id: string;
@ -212,7 +212,7 @@ export function UserManagement() {
</Button> </Button>
<Button <Button
variant="secondary" variant="secondary"
onClick={handleEditUser} onClick={() => selectedUser && handleEditUser(selectedUser)}
disabled={!selectedUser} disabled={!selectedUser}
> >
Edit User Edit User
@ -235,7 +235,7 @@ export function UserManagement() {
</Button> </Button>
<Button <Button
variant="secondary" variant="secondary"
onClick={handleDeleteUser} onClick={() => selectedUser && handleDeleteUser(selectedUser)}
disabled={!selectedUser} disabled={!selectedUser}
> >
Delete User Delete User
@ -434,8 +434,14 @@ export function UserManagement() {
{selectedUser && ( {selectedUser && (
<Card> <Card>
<CardHeader> <CardHeader className="flex flex-row items-center justify-between">
<h3 className="text-lg font-semibold">User Details</h3> <h3 className="text-lg font-semibold">User Details</h3>
<a
href={`/users/${selectedUser.id}`}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 text-sm font-medium"
>
View Full Profile & Recommendations
</a>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">

View File

@ -1,7 +1,7 @@
import Database from 'better-sqlite3' import Database from 'better-sqlite3'
import path from 'path' import path from 'path'
import fs from 'fs' import fs from 'fs'
import { IDatabase, User, Client, FitnessProfile, Attendance, DatabaseConfig } from './types' import { IDatabase, User, Client, FitnessProfile, Attendance, Recommendation, DatabaseConfig } from './types'
export class SQLiteDatabase implements IDatabase { export class SQLiteDatabase implements IDatabase {
private db: Database.Database | null = null private db: Database.Database | null = null
@ -120,6 +120,24 @@ export class SQLiteDatabase implements IDatabase {
) )
`) `)
// Recommendations table
this.db.exec(`
CREATE TABLE IF NOT EXISTS recommendations (
id TEXT PRIMARY KEY,
userId TEXT NOT NULL,
type TEXT NOT NULL CHECK (type IN ('short_term', 'medium_term', 'long_term')),
content TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('pending', 'completed')),
createdAt DATETIME NOT NULL,
updatedAt DATETIME NOT NULL,
FOREIGN KEY (userId) REFERENCES users (id) ON DELETE CASCADE
)
`)
this.db.exec(`
CREATE INDEX IF NOT EXISTS idx_recommendations_userId ON recommendations(userId);
`)
this.db.exec(` this.db.exec(`
CREATE INDEX IF NOT EXISTS idx_attendance_clientId ON attendance(clientId); CREATE INDEX IF NOT EXISTS idx_attendance_clientId ON attendance(clientId);
CREATE INDEX IF NOT EXISTS idx_attendance_checkInTime ON attendance(checkInTime); CREATE INDEX IF NOT EXISTS idx_attendance_checkInTime ON attendance(checkInTime);
@ -141,8 +159,8 @@ export class SQLiteDatabase implements IDatabase {
} }
const stmt = this.db.prepare( const stmt = this.db.prepare(
`INSERT INTO users (id, email, firstName, lastName, password, phone, role, createdAt, updatedAt) `INSERT INTO users(id, email, firstName, lastName, password, phone, role, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)`
) )
stmt.run( stmt.run(
@ -191,7 +209,7 @@ export class SQLiteDatabase implements IDatabase {
values.push(new Date().toISOString()) // updatedAt values.push(new Date().toISOString()) // updatedAt
values.push(id) values.push(id)
const stmt = this.db.prepare(`UPDATE users SET ${setClause}, updatedAt = ? WHERE id = ?`) const stmt = this.db.prepare(`UPDATE users SET ${setClause}, updatedAt = ? WHERE id = ? `)
stmt.run(values) stmt.run(values)
return this.getUserById(id) return this.getUserById(id)
@ -227,8 +245,8 @@ export class SQLiteDatabase implements IDatabase {
const client: Client = { id, ...clientData } const client: Client = { id, ...clientData }
const stmt = this.db.prepare( const stmt = this.db.prepare(
`INSERT INTO clients (id, userId, membershipType, membershipStatus, joinDate) `INSERT INTO clients(id, userId, membershipType, membershipStatus, joinDate)
VALUES (?, ?, ?, ?, ?)` VALUES(?, ?, ?, ?, ?)`
) )
stmt.run( stmt.run(
@ -273,7 +291,7 @@ export class SQLiteDatabase implements IDatabase {
const values = fields.map(field => (updates as any)[field]) const values = fields.map(field => (updates as any)[field])
values.push(id) values.push(id)
const stmt = this.db.prepare(`UPDATE clients SET ${setClause} WHERE id = ?`) const stmt = this.db.prepare(`UPDATE clients SET ${setClause} WHERE id = ? `)
stmt.run(values) stmt.run(values)
return this.getClientById(id) return this.getClientById(id)
@ -300,9 +318,9 @@ export class SQLiteDatabase implements IDatabase {
const stmt = this.db.prepare( const stmt = this.db.prepare(
`INSERT INTO fitness_profiles `INSERT INTO fitness_profiles
(userId, height, weight, age, gender, activityLevel, fitnessGoals, (userId, height, weight, age, gender, activityLevel, fitnessGoals,
exerciseHabits, dietHabits, medicalConditions, allergies, injuries, createdAt, updatedAt) exerciseHabits, dietHabits, medicalConditions, allergies, injuries, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
) )
stmt.run( stmt.run(
@ -346,7 +364,7 @@ export class SQLiteDatabase implements IDatabase {
values.push(new Date().toISOString()) // updatedAt values.push(new Date().toISOString()) // updatedAt
values.push(userId) values.push(userId)
const stmt = this.db.prepare(`UPDATE fitness_profiles SET ${setClause}, updatedAt = ? WHERE userId = ?`) const stmt = this.db.prepare(`UPDATE fitness_profiles SET ${setClause}, updatedAt = ? WHERE userId = ? `)
stmt.run(values) stmt.run(values)
return this.getFitnessProfileByUserId(userId) return this.getFitnessProfileByUserId(userId)
@ -377,8 +395,8 @@ export class SQLiteDatabase implements IDatabase {
} }
const stmt = this.db.prepare( const stmt = this.db.prepare(
`INSERT INTO attendance (id, clientId, checkInTime, type, notes, createdAt) `INSERT INTO attendance(id, clientId, checkInTime, type, notes, createdAt)
VALUES (?, ?, ?, ?, ?, ?)` VALUES(?, ?, ?, ?, ?, ?)`
) )
stmt.run( stmt.run(
@ -490,6 +508,83 @@ export class SQLiteDatabase implements IDatabase {
} }
} }
// Recommendation operations
async createRecommendation(data: Omit<Recommendation, 'createdAt' | 'updatedAt'>): Promise<Recommendation> {
if (!this.db) throw new Error('Database not connected')
const now = new Date()
const recommendation: Recommendation = {
...data,
createdAt: now,
updatedAt: now
}
const stmt = this.db.prepare(
`INSERT INTO recommendations (id, userId, type, content, status, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?)`
)
stmt.run(
recommendation.id, recommendation.userId, recommendation.type,
recommendation.content, recommendation.status,
recommendation.createdAt.toISOString(), recommendation.updatedAt.toISOString()
)
return recommendation
}
async getRecommendationsByUserId(userId: string): Promise<Recommendation[]> {
if (!this.db) throw new Error('Database not connected')
const stmt = this.db.prepare('SELECT * FROM recommendations WHERE userId = ? ORDER BY createdAt DESC')
const rows = stmt.all(userId)
return rows.map(row => this.mapRowToRecommendation(row))
}
async updateRecommendation(id: string, updates: Partial<Recommendation>): Promise<Recommendation | null> {
if (!this.db) throw new Error('Database not connected')
const fields = Object.keys(updates).filter(key => key !== 'id' && key !== 'userId')
if (fields.length === 0) {
const stmt = this.db.prepare('SELECT * FROM recommendations WHERE id = ?')
const row = stmt.get(id)
return row ? this.mapRowToRecommendation(row) : null
}
const setClause = fields.map(field => `${field} = ?`).join(', ')
const values = fields.map(field => (updates as any)[field])
values.push(new Date().toISOString()) // updatedAt
values.push(id)
const stmt = this.db.prepare(`UPDATE recommendations SET ${setClause}, updatedAt = ? WHERE id = ?`)
stmt.run(values)
const getStmt = this.db.prepare('SELECT * FROM recommendations WHERE id = ?')
const row = getStmt.get(id)
return row ? this.mapRowToRecommendation(row) : null
}
async deleteRecommendation(id: string): Promise<boolean> {
if (!this.db) throw new Error('Database not connected')
const stmt = this.db.prepare('DELETE FROM recommendations WHERE id = ?')
const result = stmt.run(id)
return (result.changes || 0) > 0
}
private mapRowToRecommendation(row: any): Recommendation {
return {
id: row.id,
userId: row.userId,
type: row.type,
content: row.content,
status: row.status,
createdAt: new Date(row.createdAt),
updatedAt: new Date(row.updatedAt)
}
}
async getDashboardStats(): Promise<{ async getDashboardStats(): Promise<{
totalUsers: number; totalUsers: number;
activeClients: number; activeClients: number;

View File

@ -47,6 +47,16 @@ export interface Attendance {
createdAt: Date; createdAt: Date;
} }
export interface Recommendation {
id: string;
userId: string;
type: "short_term" | "medium_term" | "long_term";
content: string;
status: "pending" | "completed";
createdAt: Date;
updatedAt: Date;
}
// Database Interface - allows us to swap implementations // Database Interface - allows us to swap implementations
export interface IDatabase { export interface IDatabase {
// Connection management // Connection management
@ -93,6 +103,17 @@ export interface IDatabase {
getAllAttendance(): Promise<Attendance[]>; getAllAttendance(): Promise<Attendance[]>;
getActiveCheckIn(clientId: string): Promise<Attendance | null>; getActiveCheckIn(clientId: string): Promise<Attendance | null>;
// Recommendation operations
createRecommendation(
recommendation: Omit<Recommendation, "createdAt" | "updatedAt">,
): Promise<Recommendation>;
getRecommendationsByUserId(userId: string): Promise<Recommendation[]>;
updateRecommendation(
id: string,
updates: Partial<Recommendation>,
): Promise<Recommendation | null>;
deleteRecommendation(id: string): Promise<boolean>;
// Dashboard operations // Dashboard operations
getDashboardStats(): Promise<{ getDashboardStats(): Promise<{
totalUsers: number; totalUsers: number;

View File

@ -67,6 +67,16 @@ export default function TabLayout() {
), ),
}} }}
/> />
<Tabs.Screen
name="goals"
options={{
title: "Goals",
headerTitle: "Fitness Goals",
tabBarIcon: ({ color, size }) => (
<Ionicons name="trophy" size={size} color={color} />
),
}}
/>
</Tabs> </Tabs>
); );
} }

View File

@ -0,0 +1,272 @@
import { useState, useEffect } from "react";
import {
View,
Text,
StyleSheet,
ScrollView,
TouchableOpacity,
ActivityIndicator,
RefreshControl,
Alert,
} 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;
}
export default function GoalsScreen() {
const { userId, getToken } = useAuth();
const [recommendations, setRecommendations] = useState<Recommendation[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const fetchRecommendations = 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");
}
} catch (error) {
console.error("Error fetching recommendations:", error);
} finally {
setLoading(false);
setRefreshing(false);
}
};
useEffect(() => {
fetchRecommendations();
}, [userId]);
const onRefresh = () => {
setRefreshing(true);
fetchRecommendations();
};
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)
);
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");
}
} 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");
}
};
const renderSection = (
title: string,
type: "short_term" | "medium_term" | "long_term"
) => {
const items = recommendations.filter((r) => r.type === type);
return (
<View style={styles.section}>
<Text style={styles.sectionTitle}>{title}</Text>
{items.length === 0 ? (
<Text style={styles.emptyText}>No goals set yet.</Text>
) : (
items.map((rec) => (
<TouchableOpacity
key={rec.id}
style={[
styles.card,
rec.status === "completed" && styles.cardCompleted,
]}
onPress={() => toggleStatus(rec.id, rec.status)}
>
<View style={styles.checkbox}>
{rec.status === "completed" && (
<Ionicons name="checkmark" size={16} color="#fff" />
)}
</View>
<View style={styles.cardContent}>
<Text
style={[
styles.cardText,
rec.status === "completed" && styles.cardTextCompleted,
]}
>
{rec.content}
</Text>
<Text style={styles.dateText}>
{new Date(rec.createdAt).toLocaleDateString()}
</Text>
</View>
</TouchableOpacity>
))
)}
</View>
);
};
if (loading && !refreshing) {
return (
<View style={styles.center}>
<ActivityIndicator size="large" color="#2563eb" />
</View>
);
}
return (
<ScrollView
style={styles.container}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
}
>
<View style={styles.header}>
<Text style={styles.headerTitle}>My Goals</Text>
<Text style={styles.headerSubtitle}>
Track your fitness journey progress
</Text>
</View>
{renderSection("Short Term Goals", "short_term")}
{renderSection("Medium Term Goals", "medium_term")}
{renderSection("Long Term Goals", "long_term")}
<View style={styles.footer} />
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#f3f4f6",
},
center: {
flex: 1,
justifyContent: "center",
alignItems: "center",
},
header: {
padding: 20,
backgroundColor: "#fff",
marginBottom: 10,
},
headerTitle: {
fontSize: 28,
fontWeight: "bold",
color: "#111827",
},
headerSubtitle: {
fontSize: 16,
color: "#6b7280",
marginTop: 4,
},
section: {
padding: 20,
paddingTop: 10,
},
sectionTitle: {
fontSize: 18,
fontWeight: "600",
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",
alignItems: "center",
backgroundColor: "#fff",
},
cardContent: {
flex: 1,
},
cardText: {
fontSize: 16,
color: "#1f2937",
lineHeight: 24,
},
cardTextCompleted: {
textDecorationLine: "line-through",
color: "#9ca3af",
},
dateText: {
fontSize: 12,
color: "#9ca3af",
marginTop: 4,
},
footer: {
height: 40,
},
});

View File

@ -17,4 +17,5 @@ export const API_ENDPOINTS = {
CHECK_OUT: '/api/attendance/check-out', CHECK_OUT: '/api/attendance/check-out',
HISTORY: '/api/attendance/history', HISTORY: '/api/attendance/history',
}, },
RECOMMENDATIONS: '/api/recommendations',
} }