3 and 4 completed

This commit is contained in:
echo 2026-03-11 02:38:43 +01:00
parent 3573709d99
commit 97436c6823
21 changed files with 2168 additions and 558 deletions

Binary file not shown.

View File

@ -6,7 +6,7 @@ import log from "@/lib/logger";
export async function POST(req: Request) {
try {
const { userId, useExternalModel } = await req.json();
const { userId, useExternalModel, modelProvider } = await req.json();
if (!userId) {
return NextResponse.json(
@ -43,7 +43,78 @@ export async function POST(req: Request) {
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
const deepseekApiKey = process.env.DEEPSEEK_API_KEY;
@ -203,11 +274,12 @@ export async function POST(req: Request) {
id: crypto.randomUUID(),
userId,
fitnessProfileId: profile.id,
type: "ai_plan",
recommendationText: parsedResponse.recommendationText,
activityPlan: parsedResponse.activityPlan,
dietPlan: parsedResponse.dietPlan,
status: "pending",
generatedAt: new Date(),
updatedAt: new Date(),
});
return NextResponse.json(recommendation);

View 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 },
);
}
}

View File

@ -39,15 +39,17 @@ export default function RecommendationsPage() {
const fetchData = async () => {
try {
// Fetch users
// Fetch users - API returns { success: true, data: { users: [...] }, meta: {...} }
const usersRes = await fetch("/api/users");
const usersData = await usersRes.json();
setUsers(usersData.users || []);
const usersResult = await usersRes.json();
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 recsData = await recsRes.json();
const allRecs = recsData.recommendations || [];
const recsResult = await recsRes.json();
const allRecs =
recsResult.data?.recommendations || recsResult.recommendations || [];
setPendingRecommendations(
allRecs.filter((r: Recommendation) => r.status === "pending"),
);

View File

@ -9,12 +9,16 @@ import { toast } from "@/lib/toast";
interface Recommendation {
id: string;
userId: string;
type: "short_term" | "medium_term" | "long_term" | "ai_plan";
fitnessProfileId: string;
recommendationText: string;
activityPlan?: string;
dietPlan?: string;
status: "pending" | "completed" | "approved" | "rejected";
activityPlan: string;
dietPlan: string;
status: "pending" | "approved" | "rejected";
generatedAt: string;
approvedAt?: string | null;
approvedBy?: string | null;
createdAt: string;
updatedAt: string;
}
interface RecommendationsProps {
@ -24,10 +28,7 @@ interface RecommendationsProps {
export function Recommendations({ userId }: RecommendationsProps) {
const [recommendations, setRecommendations] = useState<Recommendation[]>([]);
const [loading, setLoading] = useState(true);
const [newRec, setNewRec] = useState<{
type: "short_term" | "medium_term" | "long_term";
content: string;
}>({ type: "short_term", content: "" });
const [generating, setGenerating] = useState(false);
useEffect(() => {
fetchRecommendations();
@ -38,8 +39,13 @@ export function Recommendations({ userId }: RecommendationsProps) {
try {
const response = await fetch(`/api/recommendations?userId=${userId}`);
if (response.ok) {
const data = await response.json();
setRecommendations(data);
const result = await response.json();
// 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) {
log.error("Failed to fetch recommendations", error);
@ -48,159 +54,179 @@ export function Recommendations({ userId }: RecommendationsProps) {
}
};
const handleAddRecommendation = async (e: React.FormEvent) => {
e.preventDefault();
const handleGenerateRecommendation = async () => {
setGenerating(true);
try {
const response = await fetch("/api/recommendations", {
const response = await fetch("/api/recommendations/generate", {
method: "POST",
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({
userId,
type: newRec.type,
content: newRec.content,
recommendationId,
status: "rejected",
}),
});
if (response.ok) {
setNewRec({ ...newRec, content: "" });
fetchRecommendations();
toast.success("Recommendation added successfully");
toast.success("Recommendation rejected");
} else {
toast.error("Failed to add recommendation");
toast.error("Failed to reject recommendation");
}
} 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>;
return (
<Card>
<CardHeader>
<h3 className="text-xl font-bold">Fitness Recommendations</h3>
<CardHeader className="flex flex-row items-center justify-between">
<h3 className="text-xl font-bold">AI Fitness Recommendations</h3>
<Button onClick={handleGenerateRecommendation} disabled={generating}>
{generating ? "Generating..." : "Generate New Recommendation"}
</Button>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{renderSection("Daily AI Plan", "ai_plan", groupedRecs.ai_plan)}
{renderSection(
"Short Term Goals",
"short_term",
groupedRecs.short_term,
)}
{renderSection(
"Medium Term Goals",
"medium_term",
groupedRecs.medium_term,
)}
{renderSection("Long Term Goals", "long_term", groupedRecs.long_term)}
{recommendations.length === 0 ? (
<p className="text-gray-500 text-center py-8">
No recommendations yet. Click "Generate New Recommendation" to
create one.
</p>
) : (
<div className="space-y-4">
{recommendations.map((rec) => (
<div
key={rec.id}
className={`p-4 rounded-lg border ${
rec.status === "approved"
? "bg-green-50 border-green-200"
: 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>
</Card>
);

View File

@ -37,9 +37,10 @@
"react-dom": "^19.1.0",
"react-hook-form": "^7.47.0",
"react-native": "0.81.5",
"react-native-chart-kit": "^6.12.0",
"react-native-safe-area-context": "~5.6.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",
"zod": "^3.22.0"
},
@ -11256,6 +11257,15 @@
"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": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@ -11375,6 +11385,12 @@
"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": {
"version": "1.1.0",
"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": {
"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",
@ -11816,9 +11848,9 @@
}
},
"node_modules/react-native-svg": {
"version": "15.15.0",
"resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.15.0.tgz",
"integrity": "sha512-/Wx6F/IZ88B/GcF88bK8K7ZseJDYt+7WGaiggyzLvTowChQ8BM5idmcd4pK+6QJP6a6DmzL2sfOMukFUn/NArg==",
"version": "15.15.3",
"resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.15.3.tgz",
"integrity": "sha512-/k4KYwPBLGcx2f5d4FjE+vCScK7QOX14cl2lIASJ28u4slHHtIhL0SZKU7u9qmRBHxTCKPoPBtN6haT1NENJNA==",
"license": "MIT",
"dependencies": {
"css-select": "^5.1.0",

View File

@ -43,9 +43,10 @@
"react-dom": "^19.1.0",
"react-hook-form": "^7.47.0",
"react-native": "0.81.5",
"react-native-chart-kit": "^6.12.0",
"react-native-safe-area-context": "~5.6.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",
"zod": "^3.22.0"
},

View File

@ -6,3 +6,8 @@
export * from "./types";
export * from "./responses";
export * from "./statistics";
export * from "./fitnessProfile";
export * from "./attendance";
export * from "./recommendations";
export * from "./client";

View 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;
}

View 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;
}

View File

@ -185,6 +185,48 @@ export interface GymResponse {
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
*/

View File

@ -12,6 +12,7 @@ import { useAuth } from "@clerk/clerk-expo";
import { LinearGradient } from "expo-linear-gradient";
import { Ionicons } from "@expo/vector-icons";
import { attendanceApi, Attendance } from "../../api/attendance";
import { AttendanceCalendar } from "../../components/AttendanceCalendar";
import { theme } from "../../styles/theme";
import { Animated } from "react-native";
import { getErrorMessage } from "../../utils/error-helpers";
@ -186,6 +187,9 @@ export default function AttendanceScreen() {
)}
</View>
{/* Attendance Calendar */}
{history.length > 0 && <AttendanceCalendar attendanceRecords={history} />}
<Text style={styles.sectionTitle}>Recent History</Text>
{history.map((item) => (
<LinearGradient

View File

@ -14,12 +14,16 @@ import { LinearGradient } from "expo-linear-gradient";
import { theme } from "../../styles/theme";
import { GoalProgressCard } from "../../components/GoalProgressCard";
import { GoalCreationModal } from "../../components/GoalCreationModal";
import { WeeklyProgressChart } from "../../components/WeeklyProgressChart";
import { GoalTypeBreakdownChart } from "../../components/GoalTypeBreakdownChart";
import { useUser, useAuth } from "@clerk/clerk-expo";
import {
fitnessGoalsService,
type FitnessGoal,
type CreateGoalData,
} from "../../services/fitnessGoals";
import { getUserStatistics } from "../../api/statistics";
import type { UserStatisticsResponse } from "../../api/types";
import { useFocusEffect } from "expo-router";
import * as SecureStore from "expo-secure-store";
import log from "../../utils/logger";
@ -28,8 +32,12 @@ export default function GoalsScreen() {
const { user } = useUser();
const { getToken } = useAuth();
const [goals, setGoals] = useState<FitnessGoal[]>([]);
const [statistics, setStatistics] = useState<UserStatisticsResponse | null>(
null,
);
const [refreshing, setRefreshing] = useState(false);
const [isModalVisible, setIsModalVisible] = useState(false);
const [showAnalytics, setShowAnalytics] = useState(false);
const fabScale = useRef(new Animated.Value(1)).current;
const loadGoals = useCallback(async () => {
@ -60,6 +68,14 @@ export default function GoalsScreen() {
const loadedGoals = await fitnessGoalsService.getGoals(user.id, token);
setGoals(loadedGoals);
// Load statistics
try {
const stats = await getUserStatistics(user.id);
setStatistics(stats);
} catch (error) {
log.error("Failed to load statistics", error);
}
} catch (error) {
log.error("Failed to load goals", error);
}
@ -199,6 +215,47 @@ export default function GoalsScreen() {
</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 */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>
@ -348,6 +405,33 @@ const styles = StyleSheet.create({
color: "#6b7280",
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: {
padding: 20,
paddingTop: 10,

View File

@ -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 { LinearGradient } from "expo-linear-gradient";
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 { ActivityWidget } from "../../components/ActivityWidget";
import { QuickActionGrid } from "../../components/QuickActionGrid";
import { TrackMealModal } from "../../components/TrackMealModal";
import { AddWaterModal } from "../../components/AddWaterModal";
import { HydrationWidget } from "../../components/HydrationWidget";
import { WeeklyProgressWidget } from "../../components/WeeklyProgressWidget";
import { ScanFoodModal } from "../../components/ScanFoodModal";
import { Ionicons } from "@expo/vector-icons";
@ -35,13 +43,17 @@ export default function HomeScreen() {
return "Good Evening";
};
const handleSaveMeal = (meal: { type: string; name: string; calories: number }) => {
setCalories(prev => prev + meal.calories);
const handleSaveMeal = (meal: {
type: string;
name: string;
calories: number;
}) => {
setCalories((prev) => prev + meal.calories);
setTrackMealModalVisible(false);
};
const handleAddWater = (amount: number) => {
setWaterIntake(prev => prev + amount);
setWaterIntake((prev) => prev + amount);
setAddWaterModalVisible(false);
};
@ -54,7 +66,7 @@ export default function HomeScreen() {
};
const handleAddScannedFood = (scannedCalories: number) => {
setCalories(prev => prev + scannedCalories);
setCalories((prev) => prev + scannedCalories);
setScanFoodModalVisible(false);
};
@ -62,13 +74,13 @@ export default function HomeScreen() {
setCalories(0);
setWaterIntake(0);
const today = new Date().toDateString();
await AsyncStorage.setItem('lastResetDate', today);
await AsyncStorage.setItem("lastResetDate", today);
};
// Check for midnight reset
useEffect(() => {
const checkAndResetIfNeeded = async () => {
const lastResetDate = await AsyncStorage.getItem('lastResetDate');
const lastResetDate = await AsyncStorage.getItem("lastResetDate");
const today = new Date().toDateString();
if (lastResetDate !== today) {
@ -89,9 +101,12 @@ export default function HomeScreen() {
await resetAllCounters();
// Set up daily interval after first midnight
const dailyInterval = setInterval(async () => {
const dailyInterval = setInterval(
async () => {
await resetAllCounters();
}, 24 * 60 * 60 * 1000); // 24 hours
},
24 * 60 * 60 * 1000,
); // 24 hours
return () => clearInterval(dailyInterval);
}, timeUntilMidnight);
@ -104,7 +119,11 @@ export default function HomeScreen() {
<ScrollView
contentContainerStyle={styles.scrollContent}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={theme.colors.primary} />
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor={theme.colors.primary}
/>
}
>
{/* Header Section */}
@ -125,24 +144,7 @@ export default function HomeScreen() {
</View>
{/* Activity Widget */}
<ActivityWidget
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)}
/>
<ActivityWidget calories={calories} />
<TrackMealModal
visible={trackMealModalVisible}
@ -173,7 +175,7 @@ export default function HomeScreen() {
<View style={styles.activityCard}>
<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]}
>
<View style={styles.recentIconContainer}>
@ -192,7 +194,7 @@ export default function HomeScreen() {
</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]}
>
<View style={styles.recentIconContainer}>

View File

@ -1,88 +1,101 @@
import { useEffect, useState } from "react";
import React, { useState, useCallback } from "react";
import {
View,
Text,
FlatList,
ActivityIndicator,
StyleSheet,
ScrollView,
RefreshControl,
TouchableOpacity,
ActivityIndicator,
Alert,
} from "react-native";
import { useAuth } from "@clerk/clerk-expo";
import { Ionicons } from "@expo/vector-icons";
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 {
getRecommendations,
generateRecommendation,
type Recommendation,
} from "../../api/recommendations";
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() {
const { getToken, userId } = useAuth();
const { user } = useUser();
const [recommendations, setRecommendations] = useState<Recommendation[]>([]);
const [loading, setLoading] = useState(true);
const [generating, setGenerating] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const fetchRecommendations = async () => {
const loadRecommendations = useCallback(async () => {
if (!user?.id) return;
try {
if (!userId) {
log.warn("No userId available");
return;
}
const token = await getToken();
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
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);
setLoading(true);
const data = await getRecommendations(user.id);
// Filter to show only approved recommendations for regular users
const approved = data.filter((rec) => rec.status === "approved");
setRecommendations(approved);
} catch (error) {
log.error("Failed to load recommendations", error);
Alert.alert("Error", "Failed to load recommendations");
} finally {
setLoading(false);
setRefreshing(false);
}
};
}, [user?.id]);
useEffect(() => {
fetchRecommendations();
}, []);
useFocusEffect(
useCallback(() => {
loadRecommendations();
}, [loadRecommendations]),
);
const onRefresh = () => {
const onRefresh = async () => {
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 (
<View style={styles.centered}>
<ActivityIndicator size="large" color={theme.colors.primary} />
@ -92,101 +105,179 @@ export default function RecommendationsScreen() {
return (
<View style={styles.container}>
<ScrollView
contentContainerStyle={styles.scrollContent}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor={theme.colors.primary}
/>
}
>
{/* Header */}
<LinearGradient
colors={theme.gradients.primary}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.header}
>
<View>
<Text style={styles.headerTitle}>AI Recommendations</Text>
<Text style={styles.headerSubtitle}>
Personalized fitness & nutrition plans
</Text>
</View>
<View style={styles.iconContainer}>
<Ionicons name="sparkles" size={32} color="#fff" />
</View>
</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}
{/* Generate Button */}
<View style={styles.actionContainer}>
<TouchableOpacity
onPress={handleGenerateRecommendation}
disabled={generating}
activeOpacity={0.8}
>
<View style={styles.infoBannerIconContainer}>
<LinearGradient
colors={theme.gradients.primary}
style={styles.infoBannerIcon}
colors={theme.gradients.purple}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={[styles.generateButton, theme.shadows.medium]}
>
<Ionicons name="sparkles" size={16} color="#fff" />
{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>
<Text style={styles.infoBannerText}>
Personalized based on your active fitness goals and progress
{/* Recommendations List */}
<View style={styles.section}>
{recommendations.length === 0 ? (
<View style={styles.emptyState}>
<LinearGradient
colors={["rgba(139, 92, 246, 0.1)", "rgba(139, 92, 246, 0.05)"]}
style={styles.emptyCard}
>
<Ionicons
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>
</LinearGradient>
</View>
) : (
recommendations.map((recommendation) => (
<RecommendationCard
key={recommendation.id}
recommendation={recommendation}
/>
))
)}
</View>
</ScrollView>
</View>
);
}
<FlatList
data={recommendations}
keyExtractor={(item) => item.id}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
interface RecommendationCardProps {
recommendation: Recommendation;
}
contentContainerStyle={styles.listContent}
ListEmptyComponent={
<View style={styles.emptyContainer}>
<View style={styles.emptyIconContainer}>
function RecommendationCard({ recommendation }: RecommendationCardProps) {
const [expanded, setExpanded] = useState(false);
return (
<View style={styles.card}>
<LinearGradient
colors={
[
"rgba(209, 213, 219, 0.3)",
"rgba(209, 213, 219, 0.1)",
] as const
}
style={styles.emptyIconGradient}
>
<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>
}
renderItem={({ item }) => (
<LinearGradient
colors={
["rgba(255, 255, 255, 1)", "rgba(249, 250, 251, 1)"] as const
}
style={[styles.card, theme.shadows.medium]}
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.statusBadge}
style={styles.cardIcon}
>
<Text style={styles.statusText}>
{item.status.toUpperCase()}
</Text>
<Ionicons name="checkmark-circle" size={20} color="#fff" />
</LinearGradient>
<Text style={styles.date}>
{new Date(item.createdAt).toLocaleDateString()}
<View>
<Text style={styles.cardTitle}>AI Fitness Plan</Text>
<Text style={styles.cardDate}>
{new Date(recommendation.generatedAt).toLocaleDateString()}
</Text>
</View>
</View>
<TouchableOpacity onPress={() => setExpanded(!expanded)}>
<Ionicons
name={expanded ? "chevron-up" : "chevron-down"}
size={24}
color={theme.colors.gray400}
/>
</TouchableOpacity>
</View>
{/* Summary */}
<View style={styles.cardSummary}>
<Text
style={styles.summaryText}
numberOfLines={expanded ? undefined : 3}
>
{recommendation.recommendationText}
</Text>
</View>
<Text style={styles.sectionTitle}>Daily Advice</Text>
<Text style={styles.content}>{item.recommendationText}</Text>
{/* Expanded Content */}
{expanded && (
<View style={styles.expandedContent}>
{/* 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.activityPlan && (
<>
<Text style={styles.sectionTitle}>Activity Plan</Text>
<Text style={styles.content}>{item.activityPlan}</Text>
</>
)}
{item.dietPlan && (
<>
<Text style={styles.sectionTitle}>Diet Plan</Text>
<Text style={styles.content}>{item.dietPlan}</Text>
</>
{/* Diet Plan */}
<View style={styles.planSection}>
<View style={styles.planHeader}>
<Ionicons
name="restaurant"
size={20}
color={theme.colors.success}
/>
<Text style={styles.planTitle}>Diet Plan</Text>
</View>
<Text style={styles.planText}>{recommendation.dietPlan}</Text>
</View>
</View>
)}
</LinearGradient>
)}
/>
</View>
);
}
@ -196,10 +287,23 @@ const styles = StyleSheet.create({
flex: 1,
backgroundColor: theme.colors.background,
},
centered: {
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: theme.colors.background,
},
scrollContent: {
paddingBottom: 100,
},
header: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
padding: 24,
paddingTop: 60,
paddingBottom: 24,
paddingHorizontal: 24,
marginBottom: 20,
borderBottomLeftRadius: theme.borderRadius.xl,
borderBottomRightRadius: theme.borderRadius.xl,
},
@ -208,112 +312,126 @@ const styles = StyleSheet.create({
fontWeight: theme.typography.fontWeight.bold,
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",
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",
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",
},
infoBannerText: {
flex: 1,
fontSize: theme.typography.fontSize.sm,
emptyTitle: {
fontSize: theme.typography.fontSize.xl,
fontWeight: theme.typography.fontWeight.bold,
color: theme.colors.gray700,
lineHeight: 18,
fontWeight: theme.typography.fontWeight.medium,
marginTop: 16,
marginBottom: 8,
},
centered: {
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: theme.colors.background,
},
listContent: {
padding: 16,
emptyText: {
fontSize: theme.typography.fontSize.base,
color: theme.colors.gray500,
textAlign: "center",
lineHeight: 24,
},
card: {
padding: 18,
marginBottom: 14,
borderRadius: theme.borderRadius.xl,
borderWidth: 1,
borderColor: "rgba(59, 130, 246, 0.1)",
marginBottom: 16,
},
cardContent: {
borderRadius: theme.borderRadius["2xl"],
padding: 20,
},
cardHeader: {
flexDirection: "row",
justifyContent: "space-between",
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,
},
emptyIconGradient: {
width: 96,
height: 96,
borderRadius: 48,
cardHeaderLeft: {
flexDirection: "row",
alignItems: "center",
gap: 12,
},
cardIcon: {
width: 40,
height: 40,
borderRadius: 20,
justifyContent: "center",
alignItems: "center",
},
empty: {
textAlign: "center",
fontSize: theme.typography.fontSize.base,
color: theme.colors.gray700,
fontWeight: theme.typography.fontWeight.semibold,
marginBottom: 4,
cardTitle: {
fontSize: theme.typography.fontSize.lg,
fontWeight: theme.typography.fontWeight.bold,
color: theme.colors.gray800,
},
emptySub: {
textAlign: "center",
cardDate: {
fontSize: theme.typography.fontSize.sm,
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,
},
});

View File

@ -1,18 +1,76 @@
import React from 'react';
import { View, Text, StyleSheet, Dimensions } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons } from '@expo/vector-icons';
import { theme } from '../styles/theme';
import React, { useState, useEffect } from "react";
import {
View,
Text,
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 {
steps: number;
steps?: number;
calories: number;
duration: number; // in minutes
duration?: number; // in minutes
}
export function ActivityWidget({ steps, calories, duration }: ActivityWidgetProps) {
export function ActivityWidget({
steps = 0,
calories,
duration = 0,
}: ActivityWidgetProps) {
const { user } = useUser();
const [statistics, setStatistics] = useState<UserStatisticsResponse | null>(
null,
);
const [loading, setLoading] = useState(true);
useEffect(() => {
const loadStatistics = async () => {
if (!user?.id) return;
try {
const stats = await getUserStatistics(user.id);
setStatistics(stats);
} catch (error) {
console.error("Failed to load statistics:", error);
} finally {
setLoading(false);
}
};
loadStatistics();
}, [user?.id]);
// 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];
}
// Get last 7 weeks and normalize to 0-1 scale
const last7Weeks = statistics.weeklyTrend.slice(-7);
const maxCheckIns = Math.max(...last7Weeks.map((w) => w.checkIns), 1);
return last7Weeks.map((week) => {
// Normalize check-ins to 0.2-1.0 range for better visualization
const normalized = week.checkIns / maxCheckIns;
return Math.max(normalized * 0.8 + 0.2, 0.2);
});
};
const weeklyBars = getWeeklyBars();
const checkInsThisWeek = statistics?.attendance.checkInsThisWeek || 0;
const currentStreak = statistics?.attendance.currentStreak || 0;
return (
<View style={styles.container}>
<LinearGradient
@ -26,19 +84,35 @@ export function ActivityWidget({ steps, calories, duration }: ActivityWidgetProp
<Ionicons name="stats-chart" size={20} color={theme.colors.primary} />
</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="footsteps" size={20} color="#3b82f6" />
<View
style={[
styles.iconContainer,
{ backgroundColor: "rgba(59, 130, 246, 0.2)" },
]}
>
<Ionicons name="checkmark-circle" size={20} color="#3b82f6" />
</View>
<Text style={styles.statValue}>{steps.toLocaleString()}</Text>
<Text style={styles.statLabel}>Steps</Text>
<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)' }]}>
<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>
@ -48,28 +122,35 @@ export function ActivityWidget({ steps, calories, duration }: ActivityWidgetProp
<View style={styles.divider} />
<View style={styles.statItem}>
<View style={[styles.iconContainer, { backgroundColor: 'rgba(16, 185, 129, 0.2)' }]}>
<Ionicons name="time" size={20} color="#10b981" />
<View
style={[
styles.iconContainer,
{ backgroundColor: "rgba(16, 185, 129, 0.2)" },
]}
>
<Ionicons name="trophy" size={20} color="#10b981" />
</View>
<Text style={styles.statValue}>{duration}m</Text>
<Text style={styles.statLabel}>Active</Text>
<Text style={styles.statValue}>{currentStreak}</Text>
<Text style={styles.statLabel}>Day Streak</Text>
</View>
</View>
{/* Simple Bar Chart Visualization */}
{/* Weekly Bar Chart */}
<View style={styles.chartContainer}>
{[0.4, 0.6, 0.3, 0.8, 0.5, 0.9, 0.7].map((height, index) => (
{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]}
{["M", "T", "W", "T", "F", "S", "S"][index % 7]}
</Text>
</View>
))}
</View>
</>
)}
</LinearGradient>
</View>
);
@ -84,64 +165,69 @@ const styles = StyleSheet.create({
borderRadius: 24,
padding: 20,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
borderColor: "rgba(255, 255, 255, 0.1)",
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 20,
},
title: {
fontSize: 18,
fontWeight: '700',
color: '#fff',
fontWeight: "700",
color: "#fff",
},
loadingContainer: {
paddingVertical: 40,
alignItems: "center",
justifyContent: "center",
},
statsRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 24,
},
statItem: {
alignItems: 'center',
alignItems: "center",
flex: 1,
},
iconContainer: {
width: 40,
height: 40,
borderRadius: 20,
justifyContent: 'center',
alignItems: 'center',
justifyContent: "center",
alignItems: "center",
marginBottom: 8,
},
statValue: {
fontSize: 20,
fontWeight: '700',
color: '#fff',
fontWeight: "700",
color: "#fff",
marginBottom: 2,
},
statLabel: {
fontSize: 12,
color: theme.colors.gray400,
fontWeight: '500',
fontWeight: "500",
},
divider: {
width: 1,
height: 40,
backgroundColor: 'rgba(255, 255, 255, 0.1)',
backgroundColor: "rgba(255, 255, 255, 0.1)",
},
chartContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-end',
flexDirection: "row",
justifyContent: "space-between",
alignItems: "flex-end",
height: 80,
paddingTop: 10,
borderTopWidth: 1,
borderTopColor: 'rgba(255, 255, 255, 0.1)',
borderTopColor: "rgba(255, 255, 255, 0.1)",
},
barContainer: {
alignItems: 'center',
alignItems: "center",
gap: 8,
},
bar: {
@ -152,6 +238,6 @@ const styles = StyleSheet.create({
dayLabel: {
fontSize: 10,
color: theme.colors.gray500,
fontWeight: '600',
fontWeight: "600",
},
});

View 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,
},
});

View 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,
},
});

View 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,
},
});

View 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",
},
});

View File

@ -32,7 +32,10 @@ export const API_ENDPOINTS = {
FITNESS: "/api/fitness-profile",
},
CLIENTS: "/api/clients",
USERS: "/api/users",
USERS: {
LIST: "/api/users",
STATISTICS: "/api/users/statistics",
},
GYMS: "/api/gyms",
ATTENDANCE: {
CHECK_IN: "/api/attendance/check-in",