3 and 4 completed
This commit is contained in:
parent
3573709d99
commit
97436c6823
Binary file not shown.
@ -6,7 +6,7 @@ import log from "@/lib/logger";
|
|||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
try {
|
try {
|
||||||
const { userId, useExternalModel } = await req.json();
|
const { userId, useExternalModel, modelProvider } = await req.json();
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@ -43,7 +43,78 @@ export async function POST(req: Request) {
|
|||||||
|
|
||||||
let parsedResponse;
|
let parsedResponse;
|
||||||
|
|
||||||
if (useExternalModel) {
|
// Determine which AI provider to use
|
||||||
|
const provider =
|
||||||
|
modelProvider || (useExternalModel ? "deepseek" : "ollama");
|
||||||
|
|
||||||
|
if (provider === "openai") {
|
||||||
|
// Use OpenAI
|
||||||
|
const openaiApiKey = process.env.OPENAI_API_KEY;
|
||||||
|
|
||||||
|
if (!openaiApiKey) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "OpenAI API key not configured" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug("Using OpenAI model", { userId });
|
||||||
|
|
||||||
|
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 API request failed", new Error(errorText), {
|
||||||
|
status: openaiResponse.status,
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to generate recommendation from OpenAI" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const openaiData = await openaiResponse.json();
|
||||||
|
log.debug("Received OpenAI response", { openaiData });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = openaiData.choices[0].message.content;
|
||||||
|
parsedResponse = JSON.parse(content);
|
||||||
|
} catch (e) {
|
||||||
|
log.error("Failed to parse OpenAI response", e, {
|
||||||
|
response: openaiData,
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid response format from OpenAI" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (provider === "deepseek") {
|
||||||
// Use DeepSeek AI
|
// Use DeepSeek AI
|
||||||
const deepseekApiKey = process.env.DEEPSEEK_API_KEY;
|
const deepseekApiKey = process.env.DEEPSEEK_API_KEY;
|
||||||
|
|
||||||
@ -203,11 +274,12 @@ export async function POST(req: Request) {
|
|||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
userId,
|
userId,
|
||||||
fitnessProfileId: profile.id,
|
fitnessProfileId: profile.id,
|
||||||
type: "ai_plan",
|
|
||||||
recommendationText: parsedResponse.recommendationText,
|
recommendationText: parsedResponse.recommendationText,
|
||||||
activityPlan: parsedResponse.activityPlan,
|
activityPlan: parsedResponse.activityPlan,
|
||||||
dietPlan: parsedResponse.dietPlan,
|
dietPlan: parsedResponse.dietPlan,
|
||||||
status: "pending",
|
status: "pending",
|
||||||
|
generatedAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json(recommendation);
|
return NextResponse.json(recommendation);
|
||||||
|
|||||||
295
apps/admin/src/app/api/users/statistics/route.ts
Normal file
295
apps/admin/src/app/api/users/statistics/route.ts
Normal file
@ -0,0 +1,295 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "@clerk/nextjs/server";
|
||||||
|
import Database from "better-sqlite3";
|
||||||
|
import log from "@/lib/logger";
|
||||||
|
|
||||||
|
const db = new Database("./data/fitai.db");
|
||||||
|
|
||||||
|
interface WeeklyStats {
|
||||||
|
week: string; // ISO week start date
|
||||||
|
checkIns: number;
|
||||||
|
goalsCompleted: number;
|
||||||
|
avgProgress: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GoalStats {
|
||||||
|
total: number;
|
||||||
|
active: number;
|
||||||
|
completed: number;
|
||||||
|
avgProgress: number;
|
||||||
|
byType: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AttendanceStats {
|
||||||
|
totalCheckIns: number;
|
||||||
|
currentStreak: number;
|
||||||
|
longestStreak: number;
|
||||||
|
thisWeek: number;
|
||||||
|
thisMonth: number;
|
||||||
|
recentCheckIns: Array<{
|
||||||
|
id: string;
|
||||||
|
checkInTime: string;
|
||||||
|
checkOutTime: string | null;
|
||||||
|
type: string;
|
||||||
|
duration?: number; // in minutes
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DashboardStatistics {
|
||||||
|
goals: GoalStats;
|
||||||
|
attendance: AttendanceStats;
|
||||||
|
weeklyTrend: WeeklyStats[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET - Fetch dashboard statistics for authenticated user
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { userId } = await auth();
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug("Fetching statistics for user", { userId });
|
||||||
|
|
||||||
|
// Get goal statistics
|
||||||
|
const goals = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT
|
||||||
|
goal_type as goalType,
|
||||||
|
status,
|
||||||
|
progress,
|
||||||
|
target_value as targetValue,
|
||||||
|
current_value as currentValue
|
||||||
|
FROM fitness_goals
|
||||||
|
WHERE user_id = ?`,
|
||||||
|
)
|
||||||
|
.all(userId) as Array<{
|
||||||
|
goalType: string;
|
||||||
|
status: string;
|
||||||
|
progress: number;
|
||||||
|
targetValue: number | null;
|
||||||
|
currentValue: number | null;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
const activeGoals = goals.filter((g) => g.status === "active");
|
||||||
|
const completedGoals = goals.filter((g) => g.status === "completed");
|
||||||
|
|
||||||
|
const goalsByType: Record<string, number> = {};
|
||||||
|
goals.forEach((goal) => {
|
||||||
|
goalsByType[goal.goalType] = (goalsByType[goal.goalType] || 0) + 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
const avgProgress =
|
||||||
|
activeGoals.length > 0
|
||||||
|
? activeGoals.reduce((sum, g) => sum + (g.progress || 0), 0) /
|
||||||
|
activeGoals.length
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const goalStats: GoalStats = {
|
||||||
|
total: goals.length,
|
||||||
|
active: activeGoals.length,
|
||||||
|
completed: completedGoals.length,
|
||||||
|
avgProgress: Math.round(avgProgress),
|
||||||
|
byType: goalsByType,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get attendance statistics
|
||||||
|
const attendanceRecords = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT
|
||||||
|
id,
|
||||||
|
check_in_time as checkInTime,
|
||||||
|
check_out_time as checkOutTime,
|
||||||
|
type
|
||||||
|
FROM attendance
|
||||||
|
WHERE user_id = ?
|
||||||
|
ORDER BY check_in_time DESC`,
|
||||||
|
)
|
||||||
|
.all(userId) as Array<{
|
||||||
|
id: string;
|
||||||
|
checkInTime: string;
|
||||||
|
checkOutTime: string | null;
|
||||||
|
type: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
const totalCheckIns = attendanceRecords.length;
|
||||||
|
|
||||||
|
// Get recent check-ins (last 10)
|
||||||
|
const recentCheckIns = attendanceRecords.slice(0, 10).map((record) => {
|
||||||
|
let duration: number | undefined;
|
||||||
|
if (record.checkOutTime) {
|
||||||
|
const checkIn = new Date(record.checkInTime).getTime();
|
||||||
|
const checkOut = new Date(record.checkOutTime).getTime();
|
||||||
|
duration = Math.round((checkOut - checkIn) / (1000 * 60)); // minutes
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...record,
|
||||||
|
duration,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate this week's check-ins
|
||||||
|
const now = new Date();
|
||||||
|
const startOfWeek = new Date(now);
|
||||||
|
startOfWeek.setDate(now.getDate() - now.getDay()); // Sunday
|
||||||
|
startOfWeek.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const thisWeekCheckIns = attendanceRecords.filter(
|
||||||
|
(r) => new Date(r.checkInTime) >= startOfWeek,
|
||||||
|
).length;
|
||||||
|
|
||||||
|
// Calculate this month's check-ins
|
||||||
|
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
|
const thisMonthCheckIns = attendanceRecords.filter(
|
||||||
|
(r) => new Date(r.checkInTime) >= startOfMonth,
|
||||||
|
).length;
|
||||||
|
|
||||||
|
// Calculate current streak (consecutive days with check-ins)
|
||||||
|
let currentStreak = 0;
|
||||||
|
let longestStreak = 0;
|
||||||
|
let tempStreak = 0;
|
||||||
|
let lastDate: Date | null = null;
|
||||||
|
|
||||||
|
const sortedRecords = [...attendanceRecords].sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(b.checkInTime).getTime() - new Date(a.checkInTime).getTime(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const uniqueDays = new Set<string>();
|
||||||
|
sortedRecords.forEach((record) => {
|
||||||
|
const date = new Date(record.checkInTime);
|
||||||
|
const dateStr = date.toISOString().split("T")[0];
|
||||||
|
uniqueDays.add(dateStr);
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortedDays = Array.from(uniqueDays).sort().reverse();
|
||||||
|
|
||||||
|
for (let i = 0; i < sortedDays.length; i++) {
|
||||||
|
const currentDate = new Date(sortedDays[i]);
|
||||||
|
|
||||||
|
if (lastDate === null) {
|
||||||
|
tempStreak = 1;
|
||||||
|
currentStreak = 1;
|
||||||
|
} else {
|
||||||
|
const dayDiff = Math.floor(
|
||||||
|
(lastDate.getTime() - currentDate.getTime()) / (1000 * 60 * 60 * 24),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (dayDiff === 1) {
|
||||||
|
tempStreak++;
|
||||||
|
if (i === 0 || currentStreak > 0) {
|
||||||
|
currentStreak = tempStreak;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (currentStreak === 0) {
|
||||||
|
currentStreak = 0;
|
||||||
|
}
|
||||||
|
tempStreak = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
longestStreak = Math.max(longestStreak, tempStreak);
|
||||||
|
lastDate = currentDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if streak is still active (last check-in was today or yesterday)
|
||||||
|
if (sortedDays.length > 0) {
|
||||||
|
const lastCheckIn = new Date(sortedDays[0]);
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
const yesterday = new Date(today);
|
||||||
|
yesterday.setDate(yesterday.getDate() - 1);
|
||||||
|
|
||||||
|
lastCheckIn.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
if (
|
||||||
|
lastCheckIn.getTime() !== today.getTime() &&
|
||||||
|
lastCheckIn.getTime() !== yesterday.getTime()
|
||||||
|
) {
|
||||||
|
currentStreak = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const attendanceStats: AttendanceStats = {
|
||||||
|
totalCheckIns,
|
||||||
|
currentStreak,
|
||||||
|
longestStreak,
|
||||||
|
thisWeek: thisWeekCheckIns,
|
||||||
|
thisMonth: thisMonthCheckIns,
|
||||||
|
recentCheckIns,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate weekly trend (last 8 weeks)
|
||||||
|
const weeklyTrend: WeeklyStats[] = [];
|
||||||
|
for (let i = 7; i >= 0; i--) {
|
||||||
|
const weekStart = new Date(now);
|
||||||
|
weekStart.setDate(now.getDate() - now.getDay() - i * 7);
|
||||||
|
weekStart.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const weekEnd = new Date(weekStart);
|
||||||
|
weekEnd.setDate(weekStart.getDate() + 7);
|
||||||
|
|
||||||
|
const weekCheckIns = attendanceRecords.filter((r) => {
|
||||||
|
const checkInDate = new Date(r.checkInTime);
|
||||||
|
return checkInDate >= weekStart && checkInDate < weekEnd;
|
||||||
|
}).length;
|
||||||
|
|
||||||
|
const weekGoalsCompleted = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT COUNT(*) as count
|
||||||
|
FROM fitness_goals
|
||||||
|
WHERE user_id = ?
|
||||||
|
AND status = 'completed'
|
||||||
|
AND completed_date >= ?
|
||||||
|
AND completed_date < ?`,
|
||||||
|
)
|
||||||
|
.get(userId, weekStart.toISOString(), weekEnd.toISOString()) as {
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const weekActiveGoals = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT progress
|
||||||
|
FROM fitness_goals
|
||||||
|
WHERE user_id = ?
|
||||||
|
AND status = 'active'
|
||||||
|
AND created_at < ?`,
|
||||||
|
)
|
||||||
|
.all(userId, weekEnd.toISOString()) as Array<{ progress: number }>;
|
||||||
|
|
||||||
|
const weekAvgProgress =
|
||||||
|
weekActiveGoals.length > 0
|
||||||
|
? weekActiveGoals.reduce((sum, g) => sum + (g.progress || 0), 0) /
|
||||||
|
weekActiveGoals.length
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
weeklyTrend.push({
|
||||||
|
week: weekStart.toISOString().split("T")[0],
|
||||||
|
checkIns: weekCheckIns,
|
||||||
|
goalsCompleted: weekGoalsCompleted.count,
|
||||||
|
avgProgress: Math.round(weekAvgProgress),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const statistics: DashboardStatistics = {
|
||||||
|
goals: goalStats,
|
||||||
|
attendance: attendanceStats,
|
||||||
|
weeklyTrend,
|
||||||
|
};
|
||||||
|
|
||||||
|
log.debug("Statistics calculated successfully", {
|
||||||
|
userId,
|
||||||
|
totalGoals: goalStats.total,
|
||||||
|
totalCheckIns,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ statistics });
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Failed to fetch statistics", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to fetch statistics" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -39,15 +39,17 @@ export default function RecommendationsPage() {
|
|||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
try {
|
try {
|
||||||
// Fetch users
|
// Fetch users - API returns { success: true, data: { users: [...] }, meta: {...} }
|
||||||
const usersRes = await fetch("/api/users");
|
const usersRes = await fetch("/api/users");
|
||||||
const usersData = await usersRes.json();
|
const usersResult = await usersRes.json();
|
||||||
setUsers(usersData.users || []);
|
const usersArray = usersResult.data?.users || usersResult.users || [];
|
||||||
|
setUsers(usersArray);
|
||||||
|
|
||||||
// Fetch pending recommendations
|
// Fetch pending recommendations - API returns { success: true, data: { recommendations: [...] }, meta: {...} }
|
||||||
const recsRes = await fetch("/api/recommendations");
|
const recsRes = await fetch("/api/recommendations");
|
||||||
const recsData = await recsRes.json();
|
const recsResult = await recsRes.json();
|
||||||
const allRecs = recsData.recommendations || [];
|
const allRecs =
|
||||||
|
recsResult.data?.recommendations || recsResult.recommendations || [];
|
||||||
setPendingRecommendations(
|
setPendingRecommendations(
|
||||||
allRecs.filter((r: Recommendation) => r.status === "pending"),
|
allRecs.filter((r: Recommendation) => r.status === "pending"),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -9,12 +9,16 @@ import { toast } from "@/lib/toast";
|
|||||||
interface Recommendation {
|
interface Recommendation {
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
type: "short_term" | "medium_term" | "long_term" | "ai_plan";
|
fitnessProfileId: string;
|
||||||
recommendationText: string;
|
recommendationText: string;
|
||||||
activityPlan?: string;
|
activityPlan: string;
|
||||||
dietPlan?: string;
|
dietPlan: string;
|
||||||
status: "pending" | "completed" | "approved" | "rejected";
|
status: "pending" | "approved" | "rejected";
|
||||||
|
generatedAt: string;
|
||||||
|
approvedAt?: string | null;
|
||||||
|
approvedBy?: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RecommendationsProps {
|
interface RecommendationsProps {
|
||||||
@ -24,10 +28,7 @@ interface RecommendationsProps {
|
|||||||
export function Recommendations({ userId }: RecommendationsProps) {
|
export function Recommendations({ userId }: RecommendationsProps) {
|
||||||
const [recommendations, setRecommendations] = useState<Recommendation[]>([]);
|
const [recommendations, setRecommendations] = useState<Recommendation[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [newRec, setNewRec] = useState<{
|
const [generating, setGenerating] = useState(false);
|
||||||
type: "short_term" | "medium_term" | "long_term";
|
|
||||||
content: string;
|
|
||||||
}>({ type: "short_term", content: "" });
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchRecommendations();
|
fetchRecommendations();
|
||||||
@ -38,8 +39,13 @@ export function Recommendations({ userId }: RecommendationsProps) {
|
|||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/recommendations?userId=${userId}`);
|
const response = await fetch(`/api/recommendations?userId=${userId}`);
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const result = await response.json();
|
||||||
setRecommendations(data);
|
// API returns { success: true, data: [...], meta: {...} }
|
||||||
|
// Extract the recommendations array from the data field
|
||||||
|
const recsArray = Array.isArray(result.data)
|
||||||
|
? result.data
|
||||||
|
: result.data?.recommendations || [];
|
||||||
|
setRecommendations(recsArray);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error("Failed to fetch recommendations", error);
|
log.error("Failed to fetch recommendations", error);
|
||||||
@ -48,159 +54,179 @@ export function Recommendations({ userId }: RecommendationsProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddRecommendation = async (e: React.FormEvent) => {
|
const handleGenerateRecommendation = async () => {
|
||||||
e.preventDefault();
|
setGenerating(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/recommendations", {
|
const response = await fetch("/api/recommendations/generate", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ userId }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
fetchRecommendations();
|
||||||
|
toast.success("AI recommendation generated successfully");
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
toast.error(error.error || "Failed to generate recommendation");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Failed to generate recommendation", error);
|
||||||
|
toast.error("Failed to generate recommendation");
|
||||||
|
} finally {
|
||||||
|
setGenerating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApproveRecommendation = async (recommendationId: string) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/recommendations/approve", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ recommendationId }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
fetchRecommendations();
|
||||||
|
toast.success("Recommendation approved");
|
||||||
|
} else {
|
||||||
|
toast.error("Failed to approve recommendation");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Failed to approve recommendation", error);
|
||||||
|
toast.error("Failed to approve recommendation");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRejectRecommendation = async (recommendationId: string) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/recommendations", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
userId,
|
recommendationId,
|
||||||
type: newRec.type,
|
status: "rejected",
|
||||||
content: newRec.content,
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
setNewRec({ ...newRec, content: "" });
|
|
||||||
fetchRecommendations();
|
fetchRecommendations();
|
||||||
toast.success("Recommendation added successfully");
|
toast.success("Recommendation rejected");
|
||||||
} else {
|
} else {
|
||||||
toast.error("Failed to add recommendation");
|
toast.error("Failed to reject recommendation");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error("Failed to add recommendation", error);
|
log.error("Failed to reject recommendation", error);
|
||||||
|
toast.error("Failed to reject recommendation");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const groupedRecs = {
|
|
||||||
ai_plan: recommendations.filter((r) => r.type === "ai_plan"),
|
|
||||||
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" | "ai_plan",
|
|
||||||
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" || rec.status === "approved"
|
|
||||||
? "bg-green-50 border-green-200"
|
|
||||||
: "bg-white border-gray-200"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="w-full">
|
|
||||||
<p className="text-gray-700">{rec.recommendationText}</p>
|
|
||||||
{rec.type === "ai_plan" && (
|
|
||||||
<div className="mt-2 text-xs text-gray-600 space-y-1">
|
|
||||||
{rec.activityPlan && (
|
|
||||||
<p>
|
|
||||||
<span className="font-semibold">Activity:</span>{" "}
|
|
||||||
{rec.activityPlan}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{rec.dietPlan && (
|
|
||||||
<p>
|
|
||||||
<span className="font-semibold">Diet:</span>{" "}
|
|
||||||
{rec.dietPlan}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<p className="text-xs text-gray-400 mt-2">
|
|
||||||
{new Date(rec.createdAt).toLocaleDateString()} -{" "}
|
|
||||||
<span
|
|
||||||
className={
|
|
||||||
rec.status === "completed" || rec.status === "approved"
|
|
||||||
? "text-green-600 font-medium"
|
|
||||||
: "text-yellow-600"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{rec.status === "completed"
|
|
||||||
? "Completed"
|
|
||||||
: rec.status === "approved"
|
|
||||||
? "Approved"
|
|
||||||
: "Pending"}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{type !== "ai_plan" && (
|
|
||||||
<form onSubmit={handleAddRecommendation} className="mt-3 flex gap-2">
|
|
||||||
<input
|
|
||||||
type="hidden"
|
|
||||||
value={type}
|
|
||||||
onChange={() => setNewRec({ ...newRec, type: type as any })}
|
|
||||||
/>
|
|
||||||
{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: type as any, content: "" })}
|
|
||||||
className="text-xs text-gray-500"
|
|
||||||
>
|
|
||||||
+ Add New
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (loading) return <div>Loading recommendations...</div>;
|
if (loading) return <div>Loading recommendations...</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
<h3 className="text-xl font-bold">Fitness Recommendations</h3>
|
<h3 className="text-xl font-bold">AI Fitness Recommendations</h3>
|
||||||
|
<Button onClick={handleGenerateRecommendation} disabled={generating}>
|
||||||
|
{generating ? "Generating..." : "Generate New Recommendation"}
|
||||||
|
</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
{recommendations.length === 0 ? (
|
||||||
{renderSection("Daily AI Plan", "ai_plan", groupedRecs.ai_plan)}
|
<p className="text-gray-500 text-center py-8">
|
||||||
{renderSection(
|
No recommendations yet. Click "Generate New Recommendation" to
|
||||||
"Short Term Goals",
|
create one.
|
||||||
"short_term",
|
</p>
|
||||||
groupedRecs.short_term,
|
) : (
|
||||||
)}
|
<div className="space-y-4">
|
||||||
{renderSection(
|
{recommendations.map((rec) => (
|
||||||
"Medium Term Goals",
|
<div
|
||||||
"medium_term",
|
key={rec.id}
|
||||||
groupedRecs.medium_term,
|
className={`p-4 rounded-lg border ${
|
||||||
)}
|
rec.status === "approved"
|
||||||
{renderSection("Long Term Goals", "long_term", groupedRecs.long_term)}
|
? "bg-green-50 border-green-200"
|
||||||
</div>
|
: rec.status === "rejected"
|
||||||
|
? "bg-red-50 border-red-200"
|
||||||
|
: "bg-yellow-50 border-yellow-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-gray-900 mb-1">
|
||||||
|
Recommendation
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-700">{rec.recommendationText}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{rec.activityPlan && (
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-gray-900 mb-1">
|
||||||
|
Activity Plan
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-700 text-sm">
|
||||||
|
{rec.activityPlan}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{rec.dietPlan && (
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-gray-900 mb-1">
|
||||||
|
Diet Plan
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-700 text-sm">{rec.dietPlan}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between pt-2 border-t">
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
Generated: {new Date(rec.generatedAt).toLocaleString()}
|
||||||
|
{rec.approvedAt && (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
• Approved:{" "}
|
||||||
|
{new Date(rec.approvedAt).toLocaleString()}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`text-xs font-medium px-2 py-1 rounded ${
|
||||||
|
rec.status === "approved"
|
||||||
|
? "bg-green-600 text-white"
|
||||||
|
: rec.status === "rejected"
|
||||||
|
? "bg-red-600 text-white"
|
||||||
|
: "bg-yellow-600 text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{rec.status.charAt(0).toUpperCase() +
|
||||||
|
rec.status.slice(1)}
|
||||||
|
</span>
|
||||||
|
{rec.status === "pending" && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleApproveRecommendation(rec.id)}
|
||||||
|
className="bg-green-600 hover:bg-green-700"
|
||||||
|
>
|
||||||
|
Approve
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => handleRejectRecommendation(rec.id)}
|
||||||
|
>
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
40
apps/mobile/package-lock.json
generated
40
apps/mobile/package-lock.json
generated
@ -37,9 +37,10 @@
|
|||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-hook-form": "^7.47.0",
|
"react-hook-form": "^7.47.0",
|
||||||
"react-native": "0.81.5",
|
"react-native": "0.81.5",
|
||||||
|
"react-native-chart-kit": "^6.12.0",
|
||||||
"react-native-safe-area-context": "~5.6.0",
|
"react-native-safe-area-context": "~5.6.0",
|
||||||
"react-native-screens": "~4.16.0",
|
"react-native-screens": "~4.16.0",
|
||||||
"react-native-svg": "^15.15.0",
|
"react-native-svg": "^15.15.3",
|
||||||
"react-native-web": "^0.21.2",
|
"react-native-web": "^0.21.2",
|
||||||
"zod": "^3.22.0"
|
"zod": "^3.22.0"
|
||||||
},
|
},
|
||||||
@ -11256,6 +11257,15 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/paths-js": {
|
||||||
|
"version": "0.4.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/paths-js/-/paths-js-0.4.11.tgz",
|
||||||
|
"integrity": "sha512-3mqcLomDBXOo7Fo+UlaenG6f71bk1ZezPQy2JCmYHy2W2k5VKpP+Jbin9H0bjXynelTbglCqdFhSEkeIkKTYUA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.11.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@ -11375,6 +11385,12 @@
|
|||||||
"node": ">=4.0.0"
|
"node": ">=4.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/point-in-polygon": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/point-in-polygon/-/point-in-polygon-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-3ojrFwjnnw8Q9242TzgXuTD+eKiutbzyslcq1ydfu82Db2y+Ogbmyrkpv0Hgj31qwT3lbS9+QAAO/pIQM35XRw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/possible-typed-array-names": {
|
"node_modules/possible-typed-array-names": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||||
@ -11780,6 +11796,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-native-chart-kit": {
|
||||||
|
"version": "6.12.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-native-chart-kit/-/react-native-chart-kit-6.12.0.tgz",
|
||||||
|
"integrity": "sha512-nZLGyCFzZ7zmX0KjYeeSV1HKuPhl1wOMlTAqa0JhlyW62qV/1ZPXHgT8o9s8mkFaGxdqbspOeuaa6I9jUQDgnA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"lodash": "^4.17.13",
|
||||||
|
"paths-js": "^0.4.10",
|
||||||
|
"point-in-polygon": "^1.0.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "> 16.7.0",
|
||||||
|
"react-native": ">= 0.50.0",
|
||||||
|
"react-native-svg": "> 6.4.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-native-is-edge-to-edge": {
|
"node_modules/react-native-is-edge-to-edge": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.2.1.tgz",
|
||||||
@ -11816,9 +11848,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-native-svg": {
|
"node_modules/react-native-svg": {
|
||||||
"version": "15.15.0",
|
"version": "15.15.3",
|
||||||
"resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.15.3.tgz",
|
||||||
"integrity": "sha512-/Wx6F/IZ88B/GcF88bK8K7ZseJDYt+7WGaiggyzLvTowChQ8BM5idmcd4pK+6QJP6a6DmzL2sfOMukFUn/NArg==",
|
"integrity": "sha512-/k4KYwPBLGcx2f5d4FjE+vCScK7QOX14cl2lIASJ28u4slHHtIhL0SZKU7u9qmRBHxTCKPoPBtN6haT1NENJNA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"css-select": "^5.1.0",
|
"css-select": "^5.1.0",
|
||||||
|
|||||||
@ -43,9 +43,10 @@
|
|||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-hook-form": "^7.47.0",
|
"react-hook-form": "^7.47.0",
|
||||||
"react-native": "0.81.5",
|
"react-native": "0.81.5",
|
||||||
|
"react-native-chart-kit": "^6.12.0",
|
||||||
"react-native-safe-area-context": "~5.6.0",
|
"react-native-safe-area-context": "~5.6.0",
|
||||||
"react-native-screens": "~4.16.0",
|
"react-native-screens": "~4.16.0",
|
||||||
"react-native-svg": "^15.15.0",
|
"react-native-svg": "^15.15.3",
|
||||||
"react-native-web": "^0.21.2",
|
"react-native-web": "^0.21.2",
|
||||||
"zod": "^3.22.0"
|
"zod": "^3.22.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -6,3 +6,8 @@
|
|||||||
|
|
||||||
export * from "./types";
|
export * from "./types";
|
||||||
export * from "./responses";
|
export * from "./responses";
|
||||||
|
export * from "./statistics";
|
||||||
|
export * from "./fitnessProfile";
|
||||||
|
export * from "./attendance";
|
||||||
|
export * from "./recommendations";
|
||||||
|
export * from "./client";
|
||||||
|
|||||||
76
apps/mobile/src/api/recommendations.ts
Normal file
76
apps/mobile/src/api/recommendations.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import { apiClient } from "./client";
|
||||||
|
import { API_ENDPOINTS } from "../config/api";
|
||||||
|
|
||||||
|
export interface Recommendation {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
fitnessProfileId: string;
|
||||||
|
recommendationText: string;
|
||||||
|
activityPlan: string;
|
||||||
|
dietPlan: string;
|
||||||
|
status: "pending" | "approved" | "rejected";
|
||||||
|
generatedAt: string;
|
||||||
|
approvedAt?: string | null;
|
||||||
|
approvedBy?: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenerateRecommendationRequest {
|
||||||
|
userId: string;
|
||||||
|
useExternalModel?: boolean;
|
||||||
|
modelProvider?: "openai" | "deepseek" | "ollama";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get recommendations for a user
|
||||||
|
*
|
||||||
|
* @param userId - Clerk user ID
|
||||||
|
* @returns Array of recommendations
|
||||||
|
*/
|
||||||
|
export async function getRecommendations(
|
||||||
|
userId: string,
|
||||||
|
): Promise<Recommendation[]> {
|
||||||
|
const response = await apiClient.get<Recommendation[]>(
|
||||||
|
API_ENDPOINTS.RECOMMENDATIONS,
|
||||||
|
{
|
||||||
|
params: { userId },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a new AI recommendation for a user
|
||||||
|
*
|
||||||
|
* @param data - Generation request data
|
||||||
|
* @returns The generated recommendation
|
||||||
|
*/
|
||||||
|
export async function generateRecommendation(
|
||||||
|
data: GenerateRecommendationRequest,
|
||||||
|
): Promise<Recommendation> {
|
||||||
|
const response = await apiClient.post<Recommendation>(
|
||||||
|
`${API_ENDPOINTS.RECOMMENDATIONS}/generate`,
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Approve a recommendation (admin/trainer only)
|
||||||
|
*
|
||||||
|
* @param recommendationId - Recommendation ID
|
||||||
|
* @returns The approved recommendation
|
||||||
|
*/
|
||||||
|
export async function approveRecommendation(
|
||||||
|
recommendationId: string,
|
||||||
|
): Promise<Recommendation> {
|
||||||
|
const response = await apiClient.post<Recommendation>(
|
||||||
|
`${API_ENDPOINTS.RECOMMENDATIONS}/approve`,
|
||||||
|
{ id: recommendationId },
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
22
apps/mobile/src/api/statistics.ts
Normal file
22
apps/mobile/src/api/statistics.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { apiClient } from "./client";
|
||||||
|
import { API_ENDPOINTS } from "../config/api";
|
||||||
|
import type { UserStatisticsResponse } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch user statistics including goals, attendance, and weekly trends
|
||||||
|
*
|
||||||
|
* @param userId - Clerk user ID
|
||||||
|
* @returns User statistics data
|
||||||
|
*/
|
||||||
|
export async function getUserStatistics(
|
||||||
|
userId: string,
|
||||||
|
): Promise<UserStatisticsResponse> {
|
||||||
|
const response = await apiClient.get<UserStatisticsResponse>(
|
||||||
|
API_ENDPOINTS.USERS.STATISTICS,
|
||||||
|
{
|
||||||
|
params: { userId },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
@ -185,6 +185,48 @@ export interface GymResponse {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Statistics response types
|
||||||
|
*/
|
||||||
|
export interface GoalStatistics {
|
||||||
|
totalGoals: number;
|
||||||
|
activeGoals: number;
|
||||||
|
completedGoals: number;
|
||||||
|
averageProgress: number;
|
||||||
|
goalsByType: Array<{
|
||||||
|
goalType: string;
|
||||||
|
count: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AttendanceStatistics {
|
||||||
|
totalCheckIns: number;
|
||||||
|
currentStreak: number;
|
||||||
|
longestStreak: number;
|
||||||
|
checkInsThisWeek: number;
|
||||||
|
checkInsThisMonth: number;
|
||||||
|
recentCheckIns: Array<{
|
||||||
|
id: string;
|
||||||
|
checkInTime: string;
|
||||||
|
checkOutTime?: string | null;
|
||||||
|
duration?: number | null;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WeeklyTrendData {
|
||||||
|
weekLabel: string;
|
||||||
|
checkIns: number;
|
||||||
|
goalsCompleted: number;
|
||||||
|
averageProgress: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserStatisticsResponse {
|
||||||
|
userId: string;
|
||||||
|
goals: GoalStatistics;
|
||||||
|
attendance: AttendanceStatistics;
|
||||||
|
weeklyTrend: WeeklyTrendData[];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Common error codes
|
* Common error codes
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import { useAuth } from "@clerk/clerk-expo";
|
|||||||
import { LinearGradient } from "expo-linear-gradient";
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { attendanceApi, Attendance } from "../../api/attendance";
|
import { attendanceApi, Attendance } from "../../api/attendance";
|
||||||
|
import { AttendanceCalendar } from "../../components/AttendanceCalendar";
|
||||||
import { theme } from "../../styles/theme";
|
import { theme } from "../../styles/theme";
|
||||||
import { Animated } from "react-native";
|
import { Animated } from "react-native";
|
||||||
import { getErrorMessage } from "../../utils/error-helpers";
|
import { getErrorMessage } from "../../utils/error-helpers";
|
||||||
@ -186,6 +187,9 @@ export default function AttendanceScreen() {
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* Attendance Calendar */}
|
||||||
|
{history.length > 0 && <AttendanceCalendar attendanceRecords={history} />}
|
||||||
|
|
||||||
<Text style={styles.sectionTitle}>Recent History</Text>
|
<Text style={styles.sectionTitle}>Recent History</Text>
|
||||||
{history.map((item) => (
|
{history.map((item) => (
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
|
|||||||
@ -14,12 +14,16 @@ import { LinearGradient } from "expo-linear-gradient";
|
|||||||
import { theme } from "../../styles/theme";
|
import { theme } from "../../styles/theme";
|
||||||
import { GoalProgressCard } from "../../components/GoalProgressCard";
|
import { GoalProgressCard } from "../../components/GoalProgressCard";
|
||||||
import { GoalCreationModal } from "../../components/GoalCreationModal";
|
import { GoalCreationModal } from "../../components/GoalCreationModal";
|
||||||
|
import { WeeklyProgressChart } from "../../components/WeeklyProgressChart";
|
||||||
|
import { GoalTypeBreakdownChart } from "../../components/GoalTypeBreakdownChart";
|
||||||
import { useUser, useAuth } from "@clerk/clerk-expo";
|
import { useUser, useAuth } from "@clerk/clerk-expo";
|
||||||
import {
|
import {
|
||||||
fitnessGoalsService,
|
fitnessGoalsService,
|
||||||
type FitnessGoal,
|
type FitnessGoal,
|
||||||
type CreateGoalData,
|
type CreateGoalData,
|
||||||
} from "../../services/fitnessGoals";
|
} from "../../services/fitnessGoals";
|
||||||
|
import { getUserStatistics } from "../../api/statistics";
|
||||||
|
import type { UserStatisticsResponse } from "../../api/types";
|
||||||
import { useFocusEffect } from "expo-router";
|
import { useFocusEffect } from "expo-router";
|
||||||
import * as SecureStore from "expo-secure-store";
|
import * as SecureStore from "expo-secure-store";
|
||||||
import log from "../../utils/logger";
|
import log from "../../utils/logger";
|
||||||
@ -28,8 +32,12 @@ export default function GoalsScreen() {
|
|||||||
const { user } = useUser();
|
const { user } = useUser();
|
||||||
const { getToken } = useAuth();
|
const { getToken } = useAuth();
|
||||||
const [goals, setGoals] = useState<FitnessGoal[]>([]);
|
const [goals, setGoals] = useState<FitnessGoal[]>([]);
|
||||||
|
const [statistics, setStatistics] = useState<UserStatisticsResponse | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
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 fabScale = useRef(new Animated.Value(1)).current;
|
const fabScale = useRef(new Animated.Value(1)).current;
|
||||||
|
|
||||||
const loadGoals = useCallback(async () => {
|
const loadGoals = useCallback(async () => {
|
||||||
@ -60,6 +68,14 @@ export default function GoalsScreen() {
|
|||||||
|
|
||||||
const loadedGoals = await fitnessGoalsService.getGoals(user.id, token);
|
const loadedGoals = await fitnessGoalsService.getGoals(user.id, token);
|
||||||
setGoals(loadedGoals);
|
setGoals(loadedGoals);
|
||||||
|
|
||||||
|
// Load statistics
|
||||||
|
try {
|
||||||
|
const stats = await getUserStatistics(user.id);
|
||||||
|
setStatistics(stats);
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Failed to load statistics", error);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error("Failed to load goals", error);
|
log.error("Failed to load goals", error);
|
||||||
}
|
}
|
||||||
@ -199,6 +215,47 @@ export default function GoalsScreen() {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Analytics Section */}
|
||||||
|
{statistics && (
|
||||||
|
<View style={styles.analyticsSection}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.analyticsHeader}
|
||||||
|
onPress={() => setShowAnalytics(!showAnalytics)}
|
||||||
|
>
|
||||||
|
<View style={styles.analyticsHeaderLeft}>
|
||||||
|
<Ionicons
|
||||||
|
name="bar-chart-outline"
|
||||||
|
size={20}
|
||||||
|
color={theme.colors.primary}
|
||||||
|
/>
|
||||||
|
<Text style={styles.analyticsTitle}>Progress Analytics</Text>
|
||||||
|
</View>
|
||||||
|
<Ionicons
|
||||||
|
name={showAnalytics ? "chevron-up" : "chevron-down"}
|
||||||
|
size={20}
|
||||||
|
color={theme.colors.gray400}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{showAnalytics && (
|
||||||
|
<View style={styles.analyticsContent}>
|
||||||
|
{statistics.weeklyTrend.length > 0 && (
|
||||||
|
<WeeklyProgressChart
|
||||||
|
weeklyData={statistics.weeklyTrend}
|
||||||
|
title="8-Week Trend"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{statistics.goals.goalsByType.length > 0 && (
|
||||||
|
<GoalTypeBreakdownChart
|
||||||
|
data={statistics.goals.goalsByType}
|
||||||
|
title="Goals by Type"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Active Goals */}
|
{/* Active Goals */}
|
||||||
<View style={styles.section}>
|
<View style={styles.section}>
|
||||||
<Text style={styles.sectionTitle}>
|
<Text style={styles.sectionTitle}>
|
||||||
@ -348,6 +405,33 @@ const styles = StyleSheet.create({
|
|||||||
color: "#6b7280",
|
color: "#6b7280",
|
||||||
fontWeight: "500",
|
fontWeight: "500",
|
||||||
},
|
},
|
||||||
|
analyticsSection: {
|
||||||
|
padding: 16,
|
||||||
|
paddingTop: 0,
|
||||||
|
},
|
||||||
|
analyticsHeader: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: theme.colors.white,
|
||||||
|
padding: 16,
|
||||||
|
borderRadius: theme.borderRadius.xl,
|
||||||
|
marginBottom: 12,
|
||||||
|
...theme.shadows.subtle,
|
||||||
|
},
|
||||||
|
analyticsHeaderLeft: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
analyticsTitle: {
|
||||||
|
fontSize: theme.typography.fontSize.base,
|
||||||
|
fontWeight: theme.typography.fontWeight.semibold,
|
||||||
|
color: theme.colors.gray700,
|
||||||
|
},
|
||||||
|
analyticsContent: {
|
||||||
|
paddingTop: 4,
|
||||||
|
},
|
||||||
section: {
|
section: {
|
||||||
padding: 20,
|
padding: 20,
|
||||||
paddingTop: 10,
|
paddingTop: 10,
|
||||||
|
|||||||
@ -1,14 +1,22 @@
|
|||||||
import { View, Text, StyleSheet, ScrollView, RefreshControl, Image } from "react-native";
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
StyleSheet,
|
||||||
|
ScrollView,
|
||||||
|
RefreshControl,
|
||||||
|
Image,
|
||||||
|
} from "react-native";
|
||||||
import { useUser } from "@clerk/clerk-expo";
|
import { useUser } from "@clerk/clerk-expo";
|
||||||
import { LinearGradient } from "expo-linear-gradient";
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
import { useState, useCallback, useEffect } from "react";
|
import { useState, useCallback, useEffect } from "react";
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
import { theme } from "../../styles/theme";
|
import { theme } from "../../styles/theme";
|
||||||
import { ActivityWidget } from "../../components/ActivityWidget";
|
import { ActivityWidget } from "../../components/ActivityWidget";
|
||||||
import { QuickActionGrid } from "../../components/QuickActionGrid";
|
import { QuickActionGrid } from "../../components/QuickActionGrid";
|
||||||
import { TrackMealModal } from "../../components/TrackMealModal";
|
import { TrackMealModal } from "../../components/TrackMealModal";
|
||||||
import { AddWaterModal } from "../../components/AddWaterModal";
|
import { AddWaterModal } from "../../components/AddWaterModal";
|
||||||
import { HydrationWidget } from "../../components/HydrationWidget";
|
import { HydrationWidget } from "../../components/HydrationWidget";
|
||||||
|
import { WeeklyProgressWidget } from "../../components/WeeklyProgressWidget";
|
||||||
import { ScanFoodModal } from "../../components/ScanFoodModal";
|
import { ScanFoodModal } from "../../components/ScanFoodModal";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
|
||||||
@ -35,13 +43,17 @@ export default function HomeScreen() {
|
|||||||
return "Good Evening";
|
return "Good Evening";
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveMeal = (meal: { type: string; name: string; calories: number }) => {
|
const handleSaveMeal = (meal: {
|
||||||
setCalories(prev => prev + meal.calories);
|
type: string;
|
||||||
|
name: string;
|
||||||
|
calories: number;
|
||||||
|
}) => {
|
||||||
|
setCalories((prev) => prev + meal.calories);
|
||||||
setTrackMealModalVisible(false);
|
setTrackMealModalVisible(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddWater = (amount: number) => {
|
const handleAddWater = (amount: number) => {
|
||||||
setWaterIntake(prev => prev + amount);
|
setWaterIntake((prev) => prev + amount);
|
||||||
setAddWaterModalVisible(false);
|
setAddWaterModalVisible(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -54,7 +66,7 @@ export default function HomeScreen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleAddScannedFood = (scannedCalories: number) => {
|
const handleAddScannedFood = (scannedCalories: number) => {
|
||||||
setCalories(prev => prev + scannedCalories);
|
setCalories((prev) => prev + scannedCalories);
|
||||||
setScanFoodModalVisible(false);
|
setScanFoodModalVisible(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -62,13 +74,13 @@ export default function HomeScreen() {
|
|||||||
setCalories(0);
|
setCalories(0);
|
||||||
setWaterIntake(0);
|
setWaterIntake(0);
|
||||||
const today = new Date().toDateString();
|
const today = new Date().toDateString();
|
||||||
await AsyncStorage.setItem('lastResetDate', today);
|
await AsyncStorage.setItem("lastResetDate", today);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check for midnight reset
|
// Check for midnight reset
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkAndResetIfNeeded = async () => {
|
const checkAndResetIfNeeded = async () => {
|
||||||
const lastResetDate = await AsyncStorage.getItem('lastResetDate');
|
const lastResetDate = await AsyncStorage.getItem("lastResetDate");
|
||||||
const today = new Date().toDateString();
|
const today = new Date().toDateString();
|
||||||
|
|
||||||
if (lastResetDate !== today) {
|
if (lastResetDate !== today) {
|
||||||
@ -89,9 +101,12 @@ export default function HomeScreen() {
|
|||||||
await resetAllCounters();
|
await resetAllCounters();
|
||||||
|
|
||||||
// Set up daily interval after first midnight
|
// Set up daily interval after first midnight
|
||||||
const dailyInterval = setInterval(async () => {
|
const dailyInterval = setInterval(
|
||||||
await resetAllCounters();
|
async () => {
|
||||||
}, 24 * 60 * 60 * 1000); // 24 hours
|
await resetAllCounters();
|
||||||
|
},
|
||||||
|
24 * 60 * 60 * 1000,
|
||||||
|
); // 24 hours
|
||||||
|
|
||||||
return () => clearInterval(dailyInterval);
|
return () => clearInterval(dailyInterval);
|
||||||
}, timeUntilMidnight);
|
}, timeUntilMidnight);
|
||||||
@ -104,7 +119,11 @@ export default function HomeScreen() {
|
|||||||
<ScrollView
|
<ScrollView
|
||||||
contentContainerStyle={styles.scrollContent}
|
contentContainerStyle={styles.scrollContent}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={theme.colors.primary} />
|
<RefreshControl
|
||||||
|
refreshing={refreshing}
|
||||||
|
onRefresh={onRefresh}
|
||||||
|
tintColor={theme.colors.primary}
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{/* Header Section */}
|
{/* Header Section */}
|
||||||
@ -125,24 +144,7 @@ export default function HomeScreen() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Activity Widget */}
|
{/* Activity Widget */}
|
||||||
<ActivityWidget
|
<ActivityWidget calories={calories} />
|
||||||
steps={8432}
|
|
||||||
calories={calories}
|
|
||||||
duration={45}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Hydration Widget */}
|
|
||||||
<HydrationWidget
|
|
||||||
current={waterIntake}
|
|
||||||
goal={2500}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Quick Actions */}
|
|
||||||
<QuickActionGrid
|
|
||||||
onTrackMealPress={() => setTrackMealModalVisible(true)}
|
|
||||||
onAddWaterPress={() => setAddWaterModalVisible(true)}
|
|
||||||
onScanFoodPress={() => setScanFoodModalVisible(true)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TrackMealModal
|
<TrackMealModal
|
||||||
visible={trackMealModalVisible}
|
visible={trackMealModalVisible}
|
||||||
@ -173,7 +175,7 @@ export default function HomeScreen() {
|
|||||||
|
|
||||||
<View style={styles.activityCard}>
|
<View style={styles.activityCard}>
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
colors={['rgba(255, 255, 255, 0.8)', 'rgba(255, 255, 255, 0.5)']}
|
colors={["rgba(255, 255, 255, 0.8)", "rgba(255, 255, 255, 0.5)"]}
|
||||||
style={[styles.recentItem, theme.shadows.subtle]}
|
style={[styles.recentItem, theme.shadows.subtle]}
|
||||||
>
|
>
|
||||||
<View style={styles.recentIconContainer}>
|
<View style={styles.recentIconContainer}>
|
||||||
@ -192,7 +194,7 @@ export default function HomeScreen() {
|
|||||||
</LinearGradient>
|
</LinearGradient>
|
||||||
|
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
colors={['rgba(255, 255, 255, 0.8)', 'rgba(255, 255, 255, 0.5)']}
|
colors={["rgba(255, 255, 255, 0.8)", "rgba(255, 255, 255, 0.5)"]}
|
||||||
style={[styles.recentItem, theme.shadows.subtle]}
|
style={[styles.recentItem, theme.shadows.subtle]}
|
||||||
>
|
>
|
||||||
<View style={styles.recentIconContainer}>
|
<View style={styles.recentIconContainer}>
|
||||||
|
|||||||
@ -1,88 +1,101 @@
|
|||||||
import { useEffect, useState } from "react";
|
import React, { useState, useCallback } from "react";
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
FlatList,
|
|
||||||
ActivityIndicator,
|
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
|
ScrollView,
|
||||||
RefreshControl,
|
RefreshControl,
|
||||||
|
TouchableOpacity,
|
||||||
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { useAuth } from "@clerk/clerk-expo";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { LinearGradient } from "expo-linear-gradient";
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
import { API_BASE_URL, API_ENDPOINTS } from "../../config/api";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useUser } from "@clerk/clerk-expo";
|
||||||
|
import { useFocusEffect } from "expo-router";
|
||||||
import { theme } from "../../styles/theme";
|
import { theme } from "../../styles/theme";
|
||||||
|
import {
|
||||||
|
getRecommendations,
|
||||||
|
generateRecommendation,
|
||||||
|
type Recommendation,
|
||||||
|
} from "../../api/recommendations";
|
||||||
import log from "../../utils/logger";
|
import log from "../../utils/logger";
|
||||||
|
|
||||||
interface Recommendation {
|
|
||||||
id: string;
|
|
||||||
userId: string;
|
|
||||||
recommendationText: string;
|
|
||||||
activityPlan?: string;
|
|
||||||
dietPlan?: string;
|
|
||||||
status: string;
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function RecommendationsScreen() {
|
export default function RecommendationsScreen() {
|
||||||
const { getToken, userId } = useAuth();
|
const { user } = useUser();
|
||||||
const [recommendations, setRecommendations] = useState<Recommendation[]>([]);
|
const [recommendations, setRecommendations] = useState<Recommendation[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [generating, setGenerating] = useState(false);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
const fetchRecommendations = async () => {
|
const loadRecommendations = useCallback(async () => {
|
||||||
|
if (!user?.id) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!userId) {
|
setLoading(true);
|
||||||
log.warn("No userId available");
|
const data = await getRecommendations(user.id);
|
||||||
return;
|
// Filter to show only approved recommendations for regular users
|
||||||
}
|
const approved = data.filter((rec) => rec.status === "approved");
|
||||||
|
setRecommendations(approved);
|
||||||
const token = await getToken();
|
} catch (error) {
|
||||||
const headers: Record<string, string> = {
|
log.error("Failed to load recommendations", error);
|
||||||
"Content-Type": "application/json",
|
Alert.alert("Error", "Failed to load recommendations");
|
||||||
};
|
|
||||||
|
|
||||||
if (token) {
|
|
||||||
headers["Authorization"] = `Bearer ${token}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = `${API_BASE_URL}${API_ENDPOINTS.RECOMMENDATIONS}?userId=${userId}`;
|
|
||||||
log.apiRequest("GET", url);
|
|
||||||
|
|
||||||
const res = await fetch(url, { headers });
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const errorText = await res.text();
|
|
||||||
log.error("API error fetching recommendations", {
|
|
||||||
status: res.status,
|
|
||||||
errorText,
|
|
||||||
});
|
|
||||||
throw new Error(`Network response was not ok: ${res.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
log.debug("Recommendations fetched", {
|
|
||||||
count: data.recommendations?.length || data.length || 0,
|
|
||||||
});
|
|
||||||
setRecommendations(data.recommendations || data || []);
|
|
||||||
} catch (e) {
|
|
||||||
log.error("Failed to load recommendations", e);
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setRefreshing(false);
|
|
||||||
}
|
}
|
||||||
};
|
}, [user?.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useFocusEffect(
|
||||||
fetchRecommendations();
|
useCallback(() => {
|
||||||
}, []);
|
loadRecommendations();
|
||||||
|
}, [loadRecommendations]),
|
||||||
|
);
|
||||||
|
|
||||||
const onRefresh = () => {
|
const onRefresh = async () => {
|
||||||
setRefreshing(true);
|
setRefreshing(true);
|
||||||
fetchRecommendations();
|
await loadRecommendations();
|
||||||
|
setRefreshing(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
const handleGenerateRecommendation = async () => {
|
||||||
|
if (!user?.id) return;
|
||||||
|
|
||||||
|
Alert.alert(
|
||||||
|
"Generate AI Recommendation",
|
||||||
|
"Generate a personalized fitness and nutrition plan based on your profile and goals?",
|
||||||
|
[
|
||||||
|
{ text: "Cancel", style: "cancel" },
|
||||||
|
{
|
||||||
|
text: "Generate",
|
||||||
|
onPress: async () => {
|
||||||
|
try {
|
||||||
|
setGenerating(true);
|
||||||
|
await generateRecommendation({
|
||||||
|
userId: user.id,
|
||||||
|
modelProvider: "openai",
|
||||||
|
useExternalModel: true,
|
||||||
|
});
|
||||||
|
Alert.alert(
|
||||||
|
"Success",
|
||||||
|
"AI recommendation generated! It will appear here once approved by your trainer.",
|
||||||
|
);
|
||||||
|
await loadRecommendations();
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Failed to generate recommendation", error);
|
||||||
|
Alert.alert(
|
||||||
|
"Error",
|
||||||
|
"Failed to generate recommendation. Please try again.",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setGenerating(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading && recommendations.length === 0) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.centered}>
|
<View style={styles.centered}>
|
||||||
<ActivityIndicator size="large" color={theme.colors.primary} />
|
<ActivityIndicator size="large" color={theme.colors.primary} />
|
||||||
@ -92,101 +105,179 @@ export default function RecommendationsScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<LinearGradient
|
<ScrollView
|
||||||
colors={theme.gradients.primary}
|
contentContainerStyle={styles.scrollContent}
|
||||||
start={{ x: 0, y: 0 }}
|
|
||||||
end={{ x: 1, y: 1 }}
|
|
||||||
style={styles.header}
|
|
||||||
>
|
|
||||||
<Text style={styles.headerTitle}>AI Recommendations</Text>
|
|
||||||
</LinearGradient>
|
|
||||||
|
|
||||||
{/* AI Context Info Banner with Glassmorphism */}
|
|
||||||
<LinearGradient
|
|
||||||
colors={
|
|
||||||
["rgba(59, 130, 246, 0.15)", "rgba(139, 92, 246, 0.1)"] as const
|
|
||||||
}
|
|
||||||
style={styles.infoBanner}
|
|
||||||
>
|
|
||||||
<View style={styles.infoBannerIconContainer}>
|
|
||||||
<LinearGradient
|
|
||||||
colors={theme.gradients.primary}
|
|
||||||
style={styles.infoBannerIcon}
|
|
||||||
>
|
|
||||||
<Ionicons name="sparkles" size={16} color="#fff" />
|
|
||||||
</LinearGradient>
|
|
||||||
</View>
|
|
||||||
<Text style={styles.infoBannerText}>
|
|
||||||
Personalized based on your active fitness goals and progress
|
|
||||||
</Text>
|
|
||||||
</LinearGradient>
|
|
||||||
|
|
||||||
<FlatList
|
|
||||||
data={recommendations}
|
|
||||||
keyExtractor={(item) => item.id}
|
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
|
<RefreshControl
|
||||||
|
refreshing={refreshing}
|
||||||
|
onRefresh={onRefresh}
|
||||||
|
tintColor={theme.colors.primary}
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
contentContainerStyle={styles.listContent}
|
>
|
||||||
ListEmptyComponent={
|
{/* Header */}
|
||||||
<View style={styles.emptyContainer}>
|
<LinearGradient
|
||||||
<View style={styles.emptyIconContainer}>
|
colors={theme.gradients.primary}
|
||||||
<LinearGradient
|
start={{ x: 0, y: 0 }}
|
||||||
colors={
|
end={{ x: 1, y: 1 }}
|
||||||
[
|
style={styles.header}
|
||||||
"rgba(209, 213, 219, 0.3)",
|
>
|
||||||
"rgba(209, 213, 219, 0.1)",
|
<View>
|
||||||
] as const
|
<Text style={styles.headerTitle}>AI Recommendations</Text>
|
||||||
}
|
<Text style={styles.headerSubtitle}>
|
||||||
style={styles.emptyIconGradient}
|
Personalized fitness & nutrition plans
|
||||||
>
|
</Text>
|
||||||
<Ionicons name="sparkles-outline" size={48} color="#9ca3af" />
|
|
||||||
</LinearGradient>
|
|
||||||
</View>
|
|
||||||
<Text style={styles.empty}>No recommendations available yet.</Text>
|
|
||||||
<Text style={styles.emptySub}>Pull down to refresh</Text>
|
|
||||||
</View>
|
</View>
|
||||||
}
|
<View style={styles.iconContainer}>
|
||||||
renderItem={({ item }) => (
|
<Ionicons name="sparkles" size={32} color="#fff" />
|
||||||
<LinearGradient
|
</View>
|
||||||
colors={
|
</LinearGradient>
|
||||||
["rgba(255, 255, 255, 1)", "rgba(249, 250, 251, 1)"] as const
|
|
||||||
}
|
{/* Generate Button */}
|
||||||
style={[styles.card, theme.shadows.medium]}
|
<View style={styles.actionContainer}>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleGenerateRecommendation}
|
||||||
|
disabled={generating}
|
||||||
|
activeOpacity={0.8}
|
||||||
>
|
>
|
||||||
<View style={styles.cardHeader}>
|
<LinearGradient
|
||||||
|
colors={theme.gradients.purple}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 0 }}
|
||||||
|
style={[styles.generateButton, theme.shadows.medium]}
|
||||||
|
>
|
||||||
|
{generating ? (
|
||||||
|
<ActivityIndicator size="small" color="#fff" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Ionicons
|
||||||
|
name="bulb"
|
||||||
|
size={24}
|
||||||
|
color="#fff"
|
||||||
|
style={{ marginRight: 12 }}
|
||||||
|
/>
|
||||||
|
<Text style={styles.generateButtonText}>
|
||||||
|
Generate New Plan
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</LinearGradient>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Recommendations List */}
|
||||||
|
<View style={styles.section}>
|
||||||
|
{recommendations.length === 0 ? (
|
||||||
|
<View style={styles.emptyState}>
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
colors={theme.gradients.success}
|
colors={["rgba(139, 92, 246, 0.1)", "rgba(139, 92, 246, 0.05)"]}
|
||||||
style={styles.statusBadge}
|
style={styles.emptyCard}
|
||||||
>
|
>
|
||||||
<Text style={styles.statusText}>
|
<Ionicons
|
||||||
{item.status.toUpperCase()}
|
name="sparkles-outline"
|
||||||
|
size={64}
|
||||||
|
color={theme.colors.purple}
|
||||||
|
/>
|
||||||
|
<Text style={styles.emptyTitle}>No Recommendations Yet</Text>
|
||||||
|
<Text style={styles.emptyText}>
|
||||||
|
Tap "Generate New Plan" to get personalized AI-powered fitness
|
||||||
|
and nutrition recommendations based on your profile and goals.
|
||||||
</Text>
|
</Text>
|
||||||
</LinearGradient>
|
</LinearGradient>
|
||||||
<Text style={styles.date}>
|
</View>
|
||||||
{new Date(item.createdAt).toLocaleDateString()}
|
) : (
|
||||||
|
recommendations.map((recommendation) => (
|
||||||
|
<RecommendationCard
|
||||||
|
key={recommendation.id}
|
||||||
|
recommendation={recommendation}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RecommendationCardProps {
|
||||||
|
recommendation: Recommendation;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecommendationCard({ recommendation }: RecommendationCardProps) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.card}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={["rgba(255, 255, 255, 1)", "rgba(249, 250, 251, 1)"]}
|
||||||
|
style={[styles.cardContent, theme.shadows.medium]}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={styles.cardHeader}>
|
||||||
|
<View style={styles.cardHeaderLeft}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={theme.gradients.success}
|
||||||
|
style={styles.cardIcon}
|
||||||
|
>
|
||||||
|
<Ionicons name="checkmark-circle" size={20} color="#fff" />
|
||||||
|
</LinearGradient>
|
||||||
|
<View>
|
||||||
|
<Text style={styles.cardTitle}>AI Fitness Plan</Text>
|
||||||
|
<Text style={styles.cardDate}>
|
||||||
|
{new Date(recommendation.generatedAt).toLocaleDateString()}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity onPress={() => setExpanded(!expanded)}>
|
||||||
|
<Ionicons
|
||||||
|
name={expanded ? "chevron-up" : "chevron-down"}
|
||||||
|
size={24}
|
||||||
|
color={theme.colors.gray400}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
<Text style={styles.sectionTitle}>Daily Advice</Text>
|
{/* Summary */}
|
||||||
<Text style={styles.content}>{item.recommendationText}</Text>
|
<View style={styles.cardSummary}>
|
||||||
|
<Text
|
||||||
|
style={styles.summaryText}
|
||||||
|
numberOfLines={expanded ? undefined : 3}
|
||||||
|
>
|
||||||
|
{recommendation.recommendationText}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
{item.activityPlan && (
|
{/* Expanded Content */}
|
||||||
<>
|
{expanded && (
|
||||||
<Text style={styles.sectionTitle}>Activity Plan</Text>
|
<View style={styles.expandedContent}>
|
||||||
<Text style={styles.content}>{item.activityPlan}</Text>
|
{/* Activity Plan */}
|
||||||
</>
|
<View style={styles.planSection}>
|
||||||
)}
|
<View style={styles.planHeader}>
|
||||||
|
<Ionicons
|
||||||
|
name="barbell"
|
||||||
|
size={20}
|
||||||
|
color={theme.colors.primary}
|
||||||
|
/>
|
||||||
|
<Text style={styles.planTitle}>Activity Plan</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.planText}>{recommendation.activityPlan}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
{item.dietPlan && (
|
{/* Diet Plan */}
|
||||||
<>
|
<View style={styles.planSection}>
|
||||||
<Text style={styles.sectionTitle}>Diet Plan</Text>
|
<View style={styles.planHeader}>
|
||||||
<Text style={styles.content}>{item.dietPlan}</Text>
|
<Ionicons
|
||||||
</>
|
name="restaurant"
|
||||||
)}
|
size={20}
|
||||||
</LinearGradient>
|
color={theme.colors.success}
|
||||||
|
/>
|
||||||
|
<Text style={styles.planTitle}>Diet Plan</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.planText}>{recommendation.dietPlan}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
)}
|
)}
|
||||||
/>
|
</LinearGradient>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -196,10 +287,23 @@ const styles = StyleSheet.create({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: theme.colors.background,
|
backgroundColor: theme.colors.background,
|
||||||
},
|
},
|
||||||
|
centered: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: theme.colors.background,
|
||||||
|
},
|
||||||
|
scrollContent: {
|
||||||
|
paddingBottom: 100,
|
||||||
|
},
|
||||||
header: {
|
header: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: 24,
|
||||||
paddingTop: 60,
|
paddingTop: 60,
|
||||||
paddingBottom: 24,
|
paddingBottom: 24,
|
||||||
paddingHorizontal: 24,
|
marginBottom: 20,
|
||||||
borderBottomLeftRadius: theme.borderRadius.xl,
|
borderBottomLeftRadius: theme.borderRadius.xl,
|
||||||
borderBottomRightRadius: theme.borderRadius.xl,
|
borderBottomRightRadius: theme.borderRadius.xl,
|
||||||
},
|
},
|
||||||
@ -208,112 +312,126 @@ const styles = StyleSheet.create({
|
|||||||
fontWeight: theme.typography.fontWeight.bold,
|
fontWeight: theme.typography.fontWeight.bold,
|
||||||
color: theme.colors.white,
|
color: theme.colors.white,
|
||||||
},
|
},
|
||||||
infoBanner: {
|
headerSubtitle: {
|
||||||
|
fontSize: theme.typography.fontSize.base,
|
||||||
|
color: "rgba(255, 255, 255, 0.9)",
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
iconContainer: {
|
||||||
|
backgroundColor: "rgba(255, 255, 255, 0.2)",
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
borderRadius: 32,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
actionContainer: {
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
generateButton: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
marginHorizontal: 16,
|
|
||||||
marginTop: 16,
|
|
||||||
marginBottom: 12,
|
|
||||||
padding: 14,
|
|
||||||
borderRadius: theme.borderRadius.lg,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: "rgba(59, 130, 246, 0.2)",
|
|
||||||
gap: 10,
|
|
||||||
},
|
|
||||||
infoBannerIconContainer: {
|
|
||||||
marginRight: 4,
|
|
||||||
},
|
|
||||||
infoBannerIcon: {
|
|
||||||
width: 32,
|
|
||||||
height: 32,
|
|
||||||
borderRadius: 16,
|
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
|
paddingVertical: 16,
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
borderRadius: theme.borderRadius.xl,
|
||||||
|
},
|
||||||
|
generateButtonText: {
|
||||||
|
fontSize: theme.typography.fontSize.lg,
|
||||||
|
fontWeight: theme.typography.fontWeight.bold,
|
||||||
|
color: theme.colors.white,
|
||||||
|
},
|
||||||
|
section: {
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
},
|
||||||
|
emptyState: {
|
||||||
|
paddingVertical: 40,
|
||||||
|
},
|
||||||
|
emptyCard: {
|
||||||
|
borderRadius: theme.borderRadius["2xl"],
|
||||||
|
padding: 32,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
},
|
},
|
||||||
infoBannerText: {
|
emptyTitle: {
|
||||||
flex: 1,
|
fontSize: theme.typography.fontSize.xl,
|
||||||
fontSize: theme.typography.fontSize.sm,
|
fontWeight: theme.typography.fontWeight.bold,
|
||||||
color: theme.colors.gray700,
|
color: theme.colors.gray700,
|
||||||
lineHeight: 18,
|
marginTop: 16,
|
||||||
fontWeight: theme.typography.fontWeight.medium,
|
marginBottom: 8,
|
||||||
},
|
},
|
||||||
centered: {
|
emptyText: {
|
||||||
flex: 1,
|
fontSize: theme.typography.fontSize.base,
|
||||||
justifyContent: "center",
|
color: theme.colors.gray500,
|
||||||
alignItems: "center",
|
textAlign: "center",
|
||||||
backgroundColor: theme.colors.background,
|
lineHeight: 24,
|
||||||
},
|
|
||||||
listContent: {
|
|
||||||
padding: 16,
|
|
||||||
},
|
},
|
||||||
card: {
|
card: {
|
||||||
padding: 18,
|
marginBottom: 16,
|
||||||
marginBottom: 14,
|
},
|
||||||
borderRadius: theme.borderRadius.xl,
|
cardContent: {
|
||||||
borderWidth: 1,
|
borderRadius: theme.borderRadius["2xl"],
|
||||||
borderColor: "rgba(59, 130, 246, 0.1)",
|
padding: 20,
|
||||||
},
|
},
|
||||||
cardHeader: {
|
cardHeader: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
marginBottom: 14,
|
|
||||||
paddingBottom: 12,
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: theme.colors.gray200,
|
|
||||||
},
|
|
||||||
statusBadge: {
|
|
||||||
paddingHorizontal: 10,
|
|
||||||
paddingVertical: 5,
|
|
||||||
borderRadius: theme.borderRadius.md,
|
|
||||||
},
|
|
||||||
statusText: {
|
|
||||||
fontSize: theme.typography.fontSize.xs,
|
|
||||||
fontWeight: theme.typography.fontWeight.semibold,
|
|
||||||
color: theme.colors.white,
|
|
||||||
},
|
|
||||||
date: {
|
|
||||||
fontSize: theme.typography.fontSize.xs,
|
|
||||||
color: theme.colors.gray600,
|
|
||||||
fontWeight: theme.typography.fontWeight.medium,
|
|
||||||
},
|
|
||||||
sectionTitle: {
|
|
||||||
fontSize: theme.typography.fontSize.base,
|
|
||||||
fontWeight: theme.typography.fontWeight.semibold,
|
|
||||||
color: theme.colors.gray900,
|
|
||||||
marginTop: 12,
|
|
||||||
marginBottom: 6,
|
|
||||||
},
|
|
||||||
content: {
|
|
||||||
fontSize: theme.typography.fontSize.sm,
|
|
||||||
color: theme.colors.gray700,
|
|
||||||
lineHeight: 20,
|
|
||||||
},
|
|
||||||
emptyContainer: {
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
paddingVertical: 60,
|
|
||||||
},
|
|
||||||
emptyIconContainer: {
|
|
||||||
marginBottom: 16,
|
marginBottom: 16,
|
||||||
},
|
},
|
||||||
emptyIconGradient: {
|
cardHeaderLeft: {
|
||||||
width: 96,
|
flexDirection: "row",
|
||||||
height: 96,
|
alignItems: "center",
|
||||||
borderRadius: 48,
|
gap: 12,
|
||||||
|
},
|
||||||
|
cardIcon: {
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
},
|
},
|
||||||
empty: {
|
cardTitle: {
|
||||||
textAlign: "center",
|
fontSize: theme.typography.fontSize.lg,
|
||||||
fontSize: theme.typography.fontSize.base,
|
fontWeight: theme.typography.fontWeight.bold,
|
||||||
color: theme.colors.gray700,
|
color: theme.colors.gray800,
|
||||||
fontWeight: theme.typography.fontWeight.semibold,
|
|
||||||
marginBottom: 4,
|
|
||||||
},
|
},
|
||||||
emptySub: {
|
cardDate: {
|
||||||
textAlign: "center",
|
|
||||||
fontSize: theme.typography.fontSize.sm,
|
fontSize: theme.typography.fontSize.sm,
|
||||||
color: theme.colors.gray500,
|
color: theme.colors.gray500,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
cardSummary: {
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
summaryText: {
|
||||||
|
fontSize: theme.typography.fontSize.base,
|
||||||
|
color: theme.colors.gray700,
|
||||||
|
lineHeight: 24,
|
||||||
|
},
|
||||||
|
expandedContent: {
|
||||||
|
marginTop: 12,
|
||||||
|
paddingTop: 16,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: theme.colors.gray200,
|
||||||
|
},
|
||||||
|
planSection: {
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
planHeader: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
planTitle: {
|
||||||
|
fontSize: theme.typography.fontSize.base,
|
||||||
|
fontWeight: theme.typography.fontWeight.bold,
|
||||||
|
color: theme.colors.gray800,
|
||||||
|
},
|
||||||
|
planText: {
|
||||||
|
fontSize: theme.typography.fontSize.base,
|
||||||
|
color: theme.colors.gray600,
|
||||||
|
lineHeight: 22,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,157 +1,243 @@
|
|||||||
import React from 'react';
|
import React, { useState, useEffect } from "react";
|
||||||
import { View, Text, StyleSheet, Dimensions } from 'react-native';
|
import {
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
View,
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
Text,
|
||||||
import { theme } from '../styles/theme';
|
StyleSheet,
|
||||||
|
Dimensions,
|
||||||
|
ActivityIndicator,
|
||||||
|
} from "react-native";
|
||||||
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useUser } from "@clerk/clerk-expo";
|
||||||
|
import { theme } from "../styles/theme";
|
||||||
|
import { getUserStatistics } from "../api/statistics";
|
||||||
|
import type { UserStatisticsResponse } from "../api/types";
|
||||||
|
|
||||||
const { width } = Dimensions.get('window');
|
const { width } = Dimensions.get("window");
|
||||||
|
|
||||||
interface ActivityWidgetProps {
|
interface ActivityWidgetProps {
|
||||||
steps: number;
|
steps?: number;
|
||||||
calories: number;
|
calories: number;
|
||||||
duration: number; // in minutes
|
duration?: number; // in minutes
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ActivityWidget({ steps, calories, duration }: ActivityWidgetProps) {
|
export function ActivityWidget({
|
||||||
return (
|
steps = 0,
|
||||||
<View style={styles.container}>
|
calories,
|
||||||
<LinearGradient
|
duration = 0,
|
||||||
colors={theme.gradients.dark}
|
}: ActivityWidgetProps) {
|
||||||
start={{ x: 0, y: 0 }}
|
const { user } = useUser();
|
||||||
end={{ x: 1, y: 1 }}
|
const [statistics, setStatistics] = useState<UserStatisticsResponse | null>(
|
||||||
style={[styles.card, theme.shadows.medium]}
|
null,
|
||||||
>
|
);
|
||||||
<View style={styles.header}>
|
const [loading, setLoading] = useState(true);
|
||||||
<Text style={styles.title}>Daily Activity</Text>
|
|
||||||
<Ionicons name="stats-chart" size={20} color={theme.colors.primary} />
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.statsRow}>
|
useEffect(() => {
|
||||||
<View style={styles.statItem}>
|
const loadStatistics = async () => {
|
||||||
<View style={[styles.iconContainer, { backgroundColor: 'rgba(59, 130, 246, 0.2)' }]}>
|
if (!user?.id) return;
|
||||||
<Ionicons name="footsteps" size={20} color="#3b82f6" />
|
|
||||||
</View>
|
|
||||||
<Text style={styles.statValue}>{steps.toLocaleString()}</Text>
|
|
||||||
<Text style={styles.statLabel}>Steps</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.divider} />
|
try {
|
||||||
|
const stats = await getUserStatistics(user.id);
|
||||||
|
setStatistics(stats);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load statistics:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
<View style={styles.statItem}>
|
loadStatistics();
|
||||||
<View style={[styles.iconContainer, { backgroundColor: 'rgba(239, 68, 68, 0.2)' }]}>
|
}, [user?.id]);
|
||||||
<Ionicons name="flame" size={20} color="#ef4444" />
|
|
||||||
</View>
|
|
||||||
<Text style={styles.statValue}>{calories}</Text>
|
|
||||||
<Text style={styles.statLabel}>Kcal</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.divider} />
|
// Calculate weekly activity bars from weekly trend data
|
||||||
|
const getWeeklyBars = () => {
|
||||||
|
if (!statistics || statistics.weeklyTrend.length === 0) {
|
||||||
|
// Fallback mock data
|
||||||
|
return [0.4, 0.6, 0.3, 0.8, 0.5, 0.9, 0.7];
|
||||||
|
}
|
||||||
|
|
||||||
<View style={styles.statItem}>
|
// Get last 7 weeks and normalize to 0-1 scale
|
||||||
<View style={[styles.iconContainer, { backgroundColor: 'rgba(16, 185, 129, 0.2)' }]}>
|
const last7Weeks = statistics.weeklyTrend.slice(-7);
|
||||||
<Ionicons name="time" size={20} color="#10b981" />
|
const maxCheckIns = Math.max(...last7Weeks.map((w) => w.checkIns), 1);
|
||||||
</View>
|
|
||||||
<Text style={styles.statValue}>{duration}m</Text>
|
|
||||||
<Text style={styles.statLabel}>Active</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Simple Bar Chart Visualization */}
|
return last7Weeks.map((week) => {
|
||||||
<View style={styles.chartContainer}>
|
// Normalize check-ins to 0.2-1.0 range for better visualization
|
||||||
{[0.4, 0.6, 0.3, 0.8, 0.5, 0.9, 0.7].map((height, index) => (
|
const normalized = week.checkIns / maxCheckIns;
|
||||||
<View key={index} style={styles.barContainer}>
|
return Math.max(normalized * 0.8 + 0.2, 0.2);
|
||||||
<LinearGradient
|
});
|
||||||
colors={theme.gradients.primaryVertical}
|
};
|
||||||
style={[styles.bar, { height: height * 60 }]}
|
|
||||||
/>
|
const weeklyBars = getWeeklyBars();
|
||||||
<Text style={styles.dayLabel}>
|
const checkInsThisWeek = statistics?.attendance.checkInsThisWeek || 0;
|
||||||
{['M', 'T', 'W', 'T', 'F', 'S', 'S'][index]}
|
const currentStreak = statistics?.attendance.currentStreak || 0;
|
||||||
</Text>
|
|
||||||
</View>
|
return (
|
||||||
))}
|
<View style={styles.container}>
|
||||||
</View>
|
<LinearGradient
|
||||||
</LinearGradient>
|
colors={theme.gradients.dark}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
style={[styles.card, theme.shadows.medium]}
|
||||||
|
>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.title}>Daily Activity</Text>
|
||||||
|
<Ionicons name="stats-chart" size={20} color={theme.colors.primary} />
|
||||||
</View>
|
</View>
|
||||||
);
|
|
||||||
|
{loading ? (
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator size="small" color={theme.colors.primary} />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<View style={styles.statsRow}>
|
||||||
|
<View style={styles.statItem}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.iconContainer,
|
||||||
|
{ backgroundColor: "rgba(59, 130, 246, 0.2)" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Ionicons name="checkmark-circle" size={20} color="#3b82f6" />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.statValue}>{checkInsThisWeek}</Text>
|
||||||
|
<Text style={styles.statLabel}>This Week</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.divider} />
|
||||||
|
|
||||||
|
<View style={styles.statItem}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.iconContainer,
|
||||||
|
{ backgroundColor: "rgba(239, 68, 68, 0.2)" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Ionicons name="flame" size={20} color="#ef4444" />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.statValue}>{calories}</Text>
|
||||||
|
<Text style={styles.statLabel}>Kcal</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.divider} />
|
||||||
|
|
||||||
|
<View style={styles.statItem}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.iconContainer,
|
||||||
|
{ backgroundColor: "rgba(16, 185, 129, 0.2)" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Ionicons name="trophy" size={20} color="#10b981" />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.statValue}>{currentStreak}</Text>
|
||||||
|
<Text style={styles.statLabel}>Day Streak</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Weekly Bar Chart */}
|
||||||
|
<View style={styles.chartContainer}>
|
||||||
|
{weeklyBars.map((height, index) => (
|
||||||
|
<View key={index} style={styles.barContainer}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={theme.gradients.primaryVertical}
|
||||||
|
style={[styles.bar, { height: height * 60 }]}
|
||||||
|
/>
|
||||||
|
<Text style={styles.dayLabel}>
|
||||||
|
{["M", "T", "W", "T", "F", "S", "S"][index % 7]}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</LinearGradient>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
marginHorizontal: 20,
|
marginHorizontal: 20,
|
||||||
marginBottom: 20,
|
marginBottom: 20,
|
||||||
},
|
},
|
||||||
card: {
|
card: {
|
||||||
borderRadius: 24,
|
borderRadius: 24,
|
||||||
padding: 20,
|
padding: 20,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
borderColor: "rgba(255, 255, 255, 0.1)",
|
||||||
},
|
},
|
||||||
header: {
|
header: {
|
||||||
flexDirection: 'row',
|
flexDirection: "row",
|
||||||
justifyContent: 'space-between',
|
justifyContent: "space-between",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
marginBottom: 20,
|
marginBottom: 20,
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: '700',
|
fontWeight: "700",
|
||||||
color: '#fff',
|
color: "#fff",
|
||||||
},
|
},
|
||||||
statsRow: {
|
loadingContainer: {
|
||||||
flexDirection: 'row',
|
paddingVertical: 40,
|
||||||
justifyContent: 'space-between',
|
alignItems: "center",
|
||||||
alignItems: 'center',
|
justifyContent: "center",
|
||||||
marginBottom: 24,
|
},
|
||||||
},
|
statsRow: {
|
||||||
statItem: {
|
flexDirection: "row",
|
||||||
alignItems: 'center',
|
justifyContent: "space-between",
|
||||||
flex: 1,
|
alignItems: "center",
|
||||||
},
|
marginBottom: 24,
|
||||||
iconContainer: {
|
},
|
||||||
width: 40,
|
statItem: {
|
||||||
height: 40,
|
alignItems: "center",
|
||||||
borderRadius: 20,
|
flex: 1,
|
||||||
justifyContent: 'center',
|
},
|
||||||
alignItems: 'center',
|
iconContainer: {
|
||||||
marginBottom: 8,
|
width: 40,
|
||||||
},
|
height: 40,
|
||||||
statValue: {
|
borderRadius: 20,
|
||||||
fontSize: 20,
|
justifyContent: "center",
|
||||||
fontWeight: '700',
|
alignItems: "center",
|
||||||
color: '#fff',
|
marginBottom: 8,
|
||||||
marginBottom: 2,
|
},
|
||||||
},
|
statValue: {
|
||||||
statLabel: {
|
fontSize: 20,
|
||||||
fontSize: 12,
|
fontWeight: "700",
|
||||||
color: theme.colors.gray400,
|
color: "#fff",
|
||||||
fontWeight: '500',
|
marginBottom: 2,
|
||||||
},
|
},
|
||||||
divider: {
|
statLabel: {
|
||||||
width: 1,
|
fontSize: 12,
|
||||||
height: 40,
|
color: theme.colors.gray400,
|
||||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
fontWeight: "500",
|
||||||
},
|
},
|
||||||
chartContainer: {
|
divider: {
|
||||||
flexDirection: 'row',
|
width: 1,
|
||||||
justifyContent: 'space-between',
|
height: 40,
|
||||||
alignItems: 'flex-end',
|
backgroundColor: "rgba(255, 255, 255, 0.1)",
|
||||||
height: 80,
|
},
|
||||||
paddingTop: 10,
|
chartContainer: {
|
||||||
borderTopWidth: 1,
|
flexDirection: "row",
|
||||||
borderTopColor: 'rgba(255, 255, 255, 0.1)',
|
justifyContent: "space-between",
|
||||||
},
|
alignItems: "flex-end",
|
||||||
barContainer: {
|
height: 80,
|
||||||
alignItems: 'center',
|
paddingTop: 10,
|
||||||
gap: 8,
|
borderTopWidth: 1,
|
||||||
},
|
borderTopColor: "rgba(255, 255, 255, 0.1)",
|
||||||
bar: {
|
},
|
||||||
width: 6,
|
barContainer: {
|
||||||
borderRadius: 3,
|
alignItems: "center",
|
||||||
opacity: 0.8,
|
gap: 8,
|
||||||
},
|
},
|
||||||
dayLabel: {
|
bar: {
|
||||||
fontSize: 10,
|
width: 6,
|
||||||
color: theme.colors.gray500,
|
borderRadius: 3,
|
||||||
fontWeight: '600',
|
opacity: 0.8,
|
||||||
},
|
},
|
||||||
|
dayLabel: {
|
||||||
|
fontSize: 10,
|
||||||
|
color: theme.colors.gray500,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
303
apps/mobile/src/components/AttendanceCalendar.tsx
Normal file
303
apps/mobile/src/components/AttendanceCalendar.tsx
Normal file
@ -0,0 +1,303 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { View, Text, StyleSheet, TouchableOpacity } from "react-native";
|
||||||
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { theme } from "../styles/theme";
|
||||||
|
|
||||||
|
interface AttendanceRecord {
|
||||||
|
id: string;
|
||||||
|
checkInTime: string;
|
||||||
|
checkOutTime?: string | null;
|
||||||
|
duration?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AttendanceCalendarProps {
|
||||||
|
attendanceRecords: AttendanceRecord[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AttendanceCalendar({
|
||||||
|
attendanceRecords,
|
||||||
|
}: AttendanceCalendarProps) {
|
||||||
|
const [currentMonth, setCurrentMonth] = useState(new Date());
|
||||||
|
const [calendarDays, setCalendarDays] = useState<
|
||||||
|
Array<{ date: Date | null; hasAttendance: boolean; isToday: boolean }>
|
||||||
|
>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
generateCalendar(currentMonth);
|
||||||
|
}, [currentMonth, attendanceRecords]);
|
||||||
|
|
||||||
|
const generateCalendar = (month: Date) => {
|
||||||
|
const year = month.getFullYear();
|
||||||
|
const monthIndex = month.getMonth();
|
||||||
|
|
||||||
|
// Get first day of month and number of days
|
||||||
|
const firstDay = new Date(year, monthIndex, 1);
|
||||||
|
const lastDay = new Date(year, monthIndex + 1, 0);
|
||||||
|
const daysInMonth = lastDay.getDate();
|
||||||
|
const startingDayOfWeek = firstDay.getDay();
|
||||||
|
|
||||||
|
// Create attendance lookup set
|
||||||
|
const attendanceDates = new Set(
|
||||||
|
attendanceRecords.map((record) =>
|
||||||
|
new Date(record.checkInTime).toDateString(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const today = new Date().toDateString();
|
||||||
|
|
||||||
|
// Build calendar array
|
||||||
|
const days: Array<{
|
||||||
|
date: Date | null;
|
||||||
|
hasAttendance: boolean;
|
||||||
|
isToday: boolean;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
// Add empty cells for days before month starts
|
||||||
|
for (let i = 0; i < startingDayOfWeek; i++) {
|
||||||
|
days.push({ date: null, hasAttendance: false, isToday: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add days of the month
|
||||||
|
for (let day = 1; day <= daysInMonth; day++) {
|
||||||
|
const date = new Date(year, monthIndex, day);
|
||||||
|
const dateString = date.toDateString();
|
||||||
|
days.push({
|
||||||
|
date,
|
||||||
|
hasAttendance: attendanceDates.has(dateString),
|
||||||
|
isToday: dateString === today,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setCalendarDays(days);
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToPreviousMonth = () => {
|
||||||
|
setCurrentMonth(
|
||||||
|
new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToNextMonth = () => {
|
||||||
|
setCurrentMonth(
|
||||||
|
new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const monthName = currentMonth.toLocaleDateString("en-US", {
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={[theme.colors.white, theme.colors.gray50]}
|
||||||
|
style={[styles.card, theme.shadows.medium]}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.title}>Attendance Calendar</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Month Navigation */}
|
||||||
|
<View style={styles.monthNav}>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={goToPreviousMonth}
|
||||||
|
style={styles.navButton}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name="chevron-back"
|
||||||
|
size={20}
|
||||||
|
color={theme.colors.primary}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.monthText}>{monthName}</Text>
|
||||||
|
<TouchableOpacity onPress={goToNextMonth} style={styles.navButton}>
|
||||||
|
<Ionicons
|
||||||
|
name="chevron-forward"
|
||||||
|
size={20}
|
||||||
|
color={theme.colors.primary}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Day Headers */}
|
||||||
|
<View style={styles.dayHeaders}>
|
||||||
|
{["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].map((day) => (
|
||||||
|
<Text key={day} style={styles.dayHeader}>
|
||||||
|
{day}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Calendar Grid */}
|
||||||
|
<View style={styles.calendarGrid}>
|
||||||
|
{calendarDays.map((day, index) => (
|
||||||
|
<View key={index} style={styles.dayCell}>
|
||||||
|
{day.date ? (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.dayContent,
|
||||||
|
day.isToday && styles.todayContent,
|
||||||
|
day.hasAttendance && styles.attendanceContent,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.dayText,
|
||||||
|
day.isToday && styles.todayText,
|
||||||
|
day.hasAttendance && styles.attendanceText,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{day.date.getDate()}
|
||||||
|
</Text>
|
||||||
|
{day.hasAttendance && <View style={styles.attendanceDot} />}
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={styles.emptyCell} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<View style={styles.legend}>
|
||||||
|
<View style={styles.legendItem}>
|
||||||
|
<View style={styles.legendDotAttendance} />
|
||||||
|
<Text style={styles.legendText}>Attended</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.legendItem}>
|
||||||
|
<View style={styles.legendDotToday} />
|
||||||
|
<Text style={styles.legendText}>Today</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</LinearGradient>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
borderRadius: theme.borderRadius["2xl"],
|
||||||
|
padding: 20,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: theme.typography.fontSize.xl,
|
||||||
|
fontWeight: theme.typography.fontWeight.bold,
|
||||||
|
color: theme.colors.gray800,
|
||||||
|
},
|
||||||
|
monthNav: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: 16,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
},
|
||||||
|
navButton: {
|
||||||
|
padding: 8,
|
||||||
|
},
|
||||||
|
monthText: {
|
||||||
|
fontSize: theme.typography.fontSize.lg,
|
||||||
|
fontWeight: theme.typography.fontWeight.semibold,
|
||||||
|
color: theme.colors.gray700,
|
||||||
|
},
|
||||||
|
dayHeaders: {
|
||||||
|
flexDirection: "row",
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
dayHeader: {
|
||||||
|
flex: 1,
|
||||||
|
textAlign: "center",
|
||||||
|
fontSize: theme.typography.fontSize.xs,
|
||||||
|
fontWeight: theme.typography.fontWeight.semibold,
|
||||||
|
color: theme.colors.gray500,
|
||||||
|
},
|
||||||
|
calendarGrid: {
|
||||||
|
flexDirection: "row",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
},
|
||||||
|
dayCell: {
|
||||||
|
width: `${100 / 7}%`,
|
||||||
|
aspectRatio: 1,
|
||||||
|
padding: 2,
|
||||||
|
},
|
||||||
|
dayContent: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
|
position: "relative",
|
||||||
|
},
|
||||||
|
todayContent: {
|
||||||
|
backgroundColor: theme.colors.primaryLight,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.primary,
|
||||||
|
},
|
||||||
|
attendanceContent: {
|
||||||
|
backgroundColor: theme.colors.successLight,
|
||||||
|
},
|
||||||
|
emptyCell: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
dayText: {
|
||||||
|
fontSize: theme.typography.fontSize.sm,
|
||||||
|
color: theme.colors.gray700,
|
||||||
|
fontWeight: theme.typography.fontWeight.medium,
|
||||||
|
},
|
||||||
|
todayText: {
|
||||||
|
color: theme.colors.white,
|
||||||
|
fontWeight: theme.typography.fontWeight.bold,
|
||||||
|
},
|
||||||
|
attendanceText: {
|
||||||
|
color: theme.colors.white,
|
||||||
|
fontWeight: theme.typography.fontWeight.bold,
|
||||||
|
},
|
||||||
|
attendanceDot: {
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 2,
|
||||||
|
width: 4,
|
||||||
|
height: 4,
|
||||||
|
borderRadius: 2,
|
||||||
|
backgroundColor: theme.colors.white,
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 20,
|
||||||
|
marginTop: 16,
|
||||||
|
paddingTop: 16,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: theme.colors.gray200,
|
||||||
|
},
|
||||||
|
legendItem: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
},
|
||||||
|
legendDotAttendance: {
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
borderRadius: 6,
|
||||||
|
backgroundColor: theme.colors.successLight,
|
||||||
|
},
|
||||||
|
legendDotToday: {
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
borderRadius: 6,
|
||||||
|
backgroundColor: theme.colors.primaryLight,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.primary,
|
||||||
|
},
|
||||||
|
legendText: {
|
||||||
|
fontSize: theme.typography.fontSize.xs,
|
||||||
|
color: theme.colors.gray600,
|
||||||
|
},
|
||||||
|
});
|
||||||
100
apps/mobile/src/components/GoalTypeBreakdownChart.tsx
Normal file
100
apps/mobile/src/components/GoalTypeBreakdownChart.tsx
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { View, Text, StyleSheet, Dimensions } from "react-native";
|
||||||
|
import { PieChart } from "react-native-chart-kit";
|
||||||
|
import { theme } from "../styles/theme";
|
||||||
|
|
||||||
|
interface GoalTypeData {
|
||||||
|
goalType: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GoalTypeBreakdownChartProps {
|
||||||
|
data: GoalTypeData[];
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GoalTypeBreakdownChart({
|
||||||
|
data,
|
||||||
|
title = "Goals by Type",
|
||||||
|
}: GoalTypeBreakdownChartProps) {
|
||||||
|
const screenWidth = Dimensions.get("window").width;
|
||||||
|
|
||||||
|
// Color palette for different goal types
|
||||||
|
const colors = [
|
||||||
|
"#3b82f6", // Blue
|
||||||
|
"#10b981", // Green
|
||||||
|
"#f59e0b", // Orange
|
||||||
|
"#8b5cf6", // Purple
|
||||||
|
"#ec4899", // Pink
|
||||||
|
"#06b6d4", // Cyan
|
||||||
|
];
|
||||||
|
|
||||||
|
// Prepare chart data
|
||||||
|
const chartData = data.map((item, index) => ({
|
||||||
|
name: item.goalType,
|
||||||
|
count: item.count,
|
||||||
|
color: colors[index % colors.length],
|
||||||
|
legendFontColor: theme.colors.gray600,
|
||||||
|
legendFontSize: 12,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const chartConfig = {
|
||||||
|
color: (opacity = 1) => `rgba(59, 130, 246, ${opacity})`,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Text style={styles.title}>{title}</Text>
|
||||||
|
<View style={styles.emptyState}>
|
||||||
|
<Text style={styles.emptyText}>No goals yet</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Text style={styles.title}>{title}</Text>
|
||||||
|
<View style={styles.chartContainer}>
|
||||||
|
<PieChart
|
||||||
|
data={chartData}
|
||||||
|
width={screenWidth - 60}
|
||||||
|
height={200}
|
||||||
|
chartConfig={chartConfig}
|
||||||
|
accessor="count"
|
||||||
|
backgroundColor="transparent"
|
||||||
|
paddingLeft="15"
|
||||||
|
absolute
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
backgroundColor: theme.colors.white,
|
||||||
|
borderRadius: theme.borderRadius.xl,
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 16,
|
||||||
|
...theme.shadows.medium,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: theme.typography.fontSize.lg,
|
||||||
|
fontWeight: theme.typography.fontWeight.bold,
|
||||||
|
color: theme.colors.gray700,
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
chartContainer: {
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
emptyState: {
|
||||||
|
paddingVertical: 40,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
fontSize: theme.typography.fontSize.sm,
|
||||||
|
color: theme.colors.gray400,
|
||||||
|
},
|
||||||
|
});
|
||||||
132
apps/mobile/src/components/WeeklyProgressChart.tsx
Normal file
132
apps/mobile/src/components/WeeklyProgressChart.tsx
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { View, Text, StyleSheet, Dimensions } from "react-native";
|
||||||
|
import { LineChart } from "react-native-chart-kit";
|
||||||
|
import { theme } from "../styles/theme";
|
||||||
|
import type { WeeklyTrendData } from "../api/types";
|
||||||
|
|
||||||
|
interface WeeklyProgressChartProps {
|
||||||
|
weeklyData: WeeklyTrendData[];
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WeeklyProgressChart({
|
||||||
|
weeklyData,
|
||||||
|
title = "Weekly Progress",
|
||||||
|
}: WeeklyProgressChartProps) {
|
||||||
|
const screenWidth = Dimensions.get("window").width;
|
||||||
|
|
||||||
|
// Prepare chart data
|
||||||
|
const labels = weeklyData.map((week) => week.weekLabel);
|
||||||
|
const checkInsData = weeklyData.map((week) => week.checkIns);
|
||||||
|
const goalsCompletedData = weeklyData.map((week) => week.goalsCompleted);
|
||||||
|
const avgProgressData = weeklyData.map((week) => week.averageProgress);
|
||||||
|
|
||||||
|
const chartConfig = {
|
||||||
|
backgroundColor: theme.colors.white,
|
||||||
|
backgroundGradientFrom: theme.colors.white,
|
||||||
|
backgroundGradientTo: theme.colors.white,
|
||||||
|
decimalPlaces: 0,
|
||||||
|
color: (opacity = 1) => `rgba(59, 130, 246, ${opacity})`,
|
||||||
|
labelColor: (opacity = 1) => `rgba(107, 114, 128, ${opacity})`,
|
||||||
|
style: {
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
},
|
||||||
|
propsForDots: {
|
||||||
|
r: "4",
|
||||||
|
strokeWidth: "2",
|
||||||
|
stroke: theme.colors.primary,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
data: checkInsData,
|
||||||
|
color: (opacity = 1) => `rgba(59, 130, 246, ${opacity})`, // Blue for check-ins
|
||||||
|
strokeWidth: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
data: goalsCompletedData,
|
||||||
|
color: (opacity = 1) => `rgba(16, 185, 129, ${opacity})`, // Green for goals
|
||||||
|
strokeWidth: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
legend: ["Check-ins", "Goals Completed"],
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Text style={styles.title}>{title}</Text>
|
||||||
|
<View style={styles.chartContainer}>
|
||||||
|
<LineChart
|
||||||
|
data={data}
|
||||||
|
width={screenWidth - 60}
|
||||||
|
height={220}
|
||||||
|
chartConfig={chartConfig}
|
||||||
|
bezier
|
||||||
|
style={styles.chart}
|
||||||
|
withInnerLines={true}
|
||||||
|
withOuterLines={true}
|
||||||
|
withVerticalLabels={true}
|
||||||
|
withHorizontalLabels={true}
|
||||||
|
fromZero={true}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={styles.legend}>
|
||||||
|
<View style={styles.legendItem}>
|
||||||
|
<View style={[styles.legendDot, { backgroundColor: "#3b82f6" }]} />
|
||||||
|
<Text style={styles.legendText}>Check-ins</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.legendItem}>
|
||||||
|
<View style={[styles.legendDot, { backgroundColor: "#10b981" }]} />
|
||||||
|
<Text style={styles.legendText}>Goals Completed</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
backgroundColor: theme.colors.white,
|
||||||
|
borderRadius: theme.borderRadius.xl,
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 16,
|
||||||
|
...theme.shadows.medium,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: theme.typography.fontSize.lg,
|
||||||
|
fontWeight: theme.typography.fontWeight.bold,
|
||||||
|
color: theme.colors.gray700,
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
chartContainer: {
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
chart: {
|
||||||
|
marginVertical: 8,
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 20,
|
||||||
|
paddingTop: 8,
|
||||||
|
},
|
||||||
|
legendItem: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
},
|
||||||
|
legendDot: {
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
borderRadius: 5,
|
||||||
|
},
|
||||||
|
legendText: {
|
||||||
|
fontSize: theme.typography.fontSize.sm,
|
||||||
|
color: theme.colors.gray500,
|
||||||
|
},
|
||||||
|
});
|
||||||
205
apps/mobile/src/components/WeeklyProgressWidget.tsx
Normal file
205
apps/mobile/src/components/WeeklyProgressWidget.tsx
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { View, Text, StyleSheet, ActivityIndicator } from "react-native";
|
||||||
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { theme } from "../styles/theme";
|
||||||
|
import { useUser } from "@clerk/clerk-expo";
|
||||||
|
import { getUserStatistics } from "../api/statistics";
|
||||||
|
import type { WeeklyTrendData } from "../api/types";
|
||||||
|
|
||||||
|
export function WeeklyProgressWidget() {
|
||||||
|
const { user } = useUser();
|
||||||
|
const [weeklyData, setWeeklyData] = useState<WeeklyTrendData[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadWeeklyData = async () => {
|
||||||
|
if (!user?.id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stats = await getUserStatistics(user.id);
|
||||||
|
// Get last 4 weeks for compact display
|
||||||
|
const last4Weeks = stats.weeklyTrend.slice(-4);
|
||||||
|
setWeeklyData(last4Weeks);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load weekly data:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadWeeklyData();
|
||||||
|
}, [user?.id]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<ActivityIndicator size="small" color={theme.colors.primary} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (weeklyData.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate totals from last 4 weeks
|
||||||
|
const totalCheckIns = weeklyData.reduce(
|
||||||
|
(sum, week) => sum + week.checkIns,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const totalGoals = weeklyData.reduce(
|
||||||
|
(sum, week) => sum + week.goalsCompleted,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const avgProgress =
|
||||||
|
weeklyData.reduce((sum, week) => sum + week.averageProgress, 0) /
|
||||||
|
weeklyData.length;
|
||||||
|
|
||||||
|
// Get max value for scaling bars
|
||||||
|
const maxCheckIns = Math.max(...weeklyData.map((w) => w.checkIns), 1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={theme.gradients.purple}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
style={styles.card}
|
||||||
|
>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<View>
|
||||||
|
<Text style={styles.title}>Weekly Progress</Text>
|
||||||
|
<Text style={styles.subtitle}>Last 4 weeks</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.iconContainer}>
|
||||||
|
<Ionicons name="trending-up" size={24} color="#fff" />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.statsRow}>
|
||||||
|
<View style={styles.statItem}>
|
||||||
|
<Text style={styles.statValue}>{totalCheckIns}</Text>
|
||||||
|
<Text style={styles.statLabel}>Check-ins</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.statDivider} />
|
||||||
|
<View style={styles.statItem}>
|
||||||
|
<Text style={styles.statValue}>{totalGoals}</Text>
|
||||||
|
<Text style={styles.statLabel}>Goals Met</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.statDivider} />
|
||||||
|
<View style={styles.statItem}>
|
||||||
|
<Text style={styles.statValue}>{Math.round(avgProgress)}%</Text>
|
||||||
|
<Text style={styles.statLabel}>Avg Progress</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.chartContainer}>
|
||||||
|
{weeklyData.map((week, index) => {
|
||||||
|
const barHeight = (week.checkIns / maxCheckIns) * 60;
|
||||||
|
return (
|
||||||
|
<View key={index} style={styles.barContainer}>
|
||||||
|
<View style={styles.barWrapper}>
|
||||||
|
<View
|
||||||
|
style={[styles.bar, { height: Math.max(barHeight, 4) }]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.barLabel}>{week.weekLabel}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</LinearGradient>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
borderRadius: theme.borderRadius["2xl"],
|
||||||
|
padding: 20,
|
||||||
|
...theme.shadows.medium,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: theme.typography.fontSize.xl,
|
||||||
|
fontWeight: theme.typography.fontWeight.bold,
|
||||||
|
color: theme.colors.white,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: theme.typography.fontSize.sm,
|
||||||
|
color: "rgba(255, 255, 255, 0.8)",
|
||||||
|
},
|
||||||
|
iconContainer: {
|
||||||
|
backgroundColor: "rgba(255, 255, 255, 0.2)",
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
borderRadius: 24,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
statsRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-around",
|
||||||
|
marginBottom: 20,
|
||||||
|
backgroundColor: "rgba(255, 255, 255, 0.15)",
|
||||||
|
borderRadius: theme.borderRadius.xl,
|
||||||
|
padding: 16,
|
||||||
|
},
|
||||||
|
statItem: {
|
||||||
|
alignItems: "center",
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
statValue: {
|
||||||
|
fontSize: theme.typography.fontSize["2xl"],
|
||||||
|
fontWeight: theme.typography.fontWeight.bold,
|
||||||
|
color: theme.colors.white,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
statLabel: {
|
||||||
|
fontSize: theme.typography.fontSize.xs,
|
||||||
|
color: "rgba(255, 255, 255, 0.7)",
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
statDivider: {
|
||||||
|
width: 1,
|
||||||
|
backgroundColor: "rgba(255, 255, 255, 0.2)",
|
||||||
|
marginHorizontal: 8,
|
||||||
|
},
|
||||||
|
chartContainer: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-around",
|
||||||
|
alignItems: "flex-end",
|
||||||
|
height: 80,
|
||||||
|
},
|
||||||
|
barContainer: {
|
||||||
|
alignItems: "center",
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
barWrapper: {
|
||||||
|
height: 60,
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
bar: {
|
||||||
|
width: 24,
|
||||||
|
backgroundColor: "rgba(255, 255, 255, 0.9)",
|
||||||
|
borderRadius: 4,
|
||||||
|
minHeight: 4,
|
||||||
|
},
|
||||||
|
barLabel: {
|
||||||
|
fontSize: 10,
|
||||||
|
color: "rgba(255, 255, 255, 0.7)",
|
||||||
|
fontWeight: "500",
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -32,7 +32,10 @@ export const API_ENDPOINTS = {
|
|||||||
FITNESS: "/api/fitness-profile",
|
FITNESS: "/api/fitness-profile",
|
||||||
},
|
},
|
||||||
CLIENTS: "/api/clients",
|
CLIENTS: "/api/clients",
|
||||||
USERS: "/api/users",
|
USERS: {
|
||||||
|
LIST: "/api/users",
|
||||||
|
STATISTICS: "/api/users/statistics",
|
||||||
|
},
|
||||||
GYMS: "/api/gyms",
|
GYMS: "/api/gyms",
|
||||||
ATTENDANCE: {
|
ATTENDANCE: {
|
||||||
CHECK_IN: "/api/attendance/check-in",
|
CHECK_IN: "/api/attendance/check-in",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user