notification system implemented
need refinements
This commit is contained in:
parent
612259f020
commit
1143f8ca02
Binary file not shown.
105
apps/admin/src/app/api/notifications/[id]/route.ts
Normal file
105
apps/admin/src/app/api/notifications/[id]/route.ts
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "@clerk/nextjs/server";
|
||||||
|
import { getDatabase } from "@/lib/database";
|
||||||
|
import log from "@/lib/logger";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/notifications/[id]
|
||||||
|
* Mark a notification as read
|
||||||
|
*/
|
||||||
|
export async function PUT(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { userId } = await auth();
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const db = await getDatabase();
|
||||||
|
|
||||||
|
// Verify the notification belongs to the user
|
||||||
|
const notifications = await db.getNotificationsByUserId(userId);
|
||||||
|
const notification = notifications.find((n) => n.id === id);
|
||||||
|
|
||||||
|
if (!notification) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Notification not found" },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedNotification = await db.markNotificationAsRead(id);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: updatedNotification,
|
||||||
|
meta: {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Failed to mark notification as read", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/notifications/[id]
|
||||||
|
* Delete a notification
|
||||||
|
*/
|
||||||
|
export async function DELETE(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { userId } = await auth();
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const db = await getDatabase();
|
||||||
|
|
||||||
|
// Verify the notification belongs to the user
|
||||||
|
const notifications = await db.getNotificationsByUserId(userId);
|
||||||
|
const notification = notifications.find((n) => n.id === id);
|
||||||
|
|
||||||
|
if (!notification) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Notification not found" },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleted = await db.deleteNotification(id);
|
||||||
|
|
||||||
|
if (!deleted) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to delete notification" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: { id },
|
||||||
|
meta: {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Failed to delete notification", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
37
apps/admin/src/app/api/notifications/mark-all-read/route.ts
Normal file
37
apps/admin/src/app/api/notifications/mark-all-read/route.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "@clerk/nextjs/server";
|
||||||
|
import { getDatabase } from "@/lib/database";
|
||||||
|
import log from "@/lib/logger";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/notifications/mark-all-read
|
||||||
|
* Mark all notifications as read for the authenticated user
|
||||||
|
*/
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { userId } = await auth();
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = await getDatabase();
|
||||||
|
await db.markAllNotificationsAsRead(userId);
|
||||||
|
|
||||||
|
log.info("All notifications marked as read", { userId });
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: { message: "All notifications marked as read" },
|
||||||
|
meta: {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Failed to mark all notifications as read", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
116
apps/admin/src/app/api/notifications/route.ts
Normal file
116
apps/admin/src/app/api/notifications/route.ts
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "@clerk/nextjs/server";
|
||||||
|
import { getDatabase } from "@/lib/database";
|
||||||
|
import log from "@/lib/logger";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/notifications
|
||||||
|
* Get all notifications for the authenticated user
|
||||||
|
*/
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { userId } = await auth();
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
log.warn("Unauthorized notification fetch attempt");
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug("Fetching notifications for user", { userId });
|
||||||
|
|
||||||
|
const db = await getDatabase();
|
||||||
|
const notifications = await db.getNotificationsByUserId(userId);
|
||||||
|
|
||||||
|
log.debug("Notifications fetched successfully", {
|
||||||
|
userId,
|
||||||
|
count: notifications.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: notifications,
|
||||||
|
meta: {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
count: notifications.length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Failed to fetch notifications", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/notifications
|
||||||
|
* Create a new notification (admin/system only)
|
||||||
|
*/
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { userId } = await auth();
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json();
|
||||||
|
const { targetUserId, title, message, type } = body;
|
||||||
|
|
||||||
|
if (!targetUserId || !title || !message || !type) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: "Missing required fields: targetUserId, title, message, type",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate notification type
|
||||||
|
const validTypes = [
|
||||||
|
"payment_reminder",
|
||||||
|
"attendance",
|
||||||
|
"promotion",
|
||||||
|
"system",
|
||||||
|
];
|
||||||
|
if (!validTypes.includes(type)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: `Invalid notification type. Must be one of: ${validTypes.join(", ")}`,
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = await getDatabase();
|
||||||
|
const notification = await db.createNotification({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
userId: targetUserId,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
type,
|
||||||
|
read: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
log.info("Notification created", {
|
||||||
|
id: notification.id,
|
||||||
|
targetUserId,
|
||||||
|
type,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: notification,
|
||||||
|
meta: {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Failed to create notification", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
71
apps/admin/src/app/api/notifications/save-token/route.ts
Normal file
71
apps/admin/src/app/api/notifications/save-token/route.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "@clerk/nextjs/server";
|
||||||
|
import { getDatabase } from "@/lib/database";
|
||||||
|
import log from "@/lib/logger";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/notifications/save-token
|
||||||
|
* Save the user's Expo push notification token
|
||||||
|
*/
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { userId } = await auth();
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json();
|
||||||
|
const { expoPushToken, deviceType } = body;
|
||||||
|
|
||||||
|
if (!expoPushToken) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Missing required field: expoPushToken" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate device type
|
||||||
|
if (deviceType && !["ios", "android"].includes(deviceType)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid deviceType. Must be 'ios' or 'android'" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = await getDatabase();
|
||||||
|
|
||||||
|
// Update user with push token
|
||||||
|
const updatedUser = await db.updateUser(userId, {
|
||||||
|
expoPushToken,
|
||||||
|
deviceType: deviceType || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!updatedUser) {
|
||||||
|
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Push token saved", {
|
||||||
|
userId,
|
||||||
|
deviceType,
|
||||||
|
tokenPrefix: expoPushToken.substring(0, 20) + "...",
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
message: "Push token saved successfully",
|
||||||
|
deviceType: updatedUser.deviceType,
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Failed to save push token", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
35
apps/admin/src/app/api/notifications/unread-count/route.ts
Normal file
35
apps/admin/src/app/api/notifications/unread-count/route.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "@clerk/nextjs/server";
|
||||||
|
import { getDatabase } from "@/lib/database";
|
||||||
|
import log from "@/lib/logger";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/notifications/unread-count
|
||||||
|
* Get count of unread notifications for the authenticated user
|
||||||
|
*/
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { userId } = await auth();
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = await getDatabase();
|
||||||
|
const count = await db.getUnreadNotificationCount(userId);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: { count },
|
||||||
|
meta: {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Failed to fetch unread notification count", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,14 +1,23 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { getDatabase } from "@/lib/database";
|
import { getDatabase } from "@/lib/database";
|
||||||
|
import log from "@/lib/logger";
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
try {
|
try {
|
||||||
const { recommendationId, status, approvedBy } = await req.json();
|
const body = await req.json();
|
||||||
|
log.debug("Approve recommendation request body", { body });
|
||||||
|
|
||||||
|
const { recommendationId, status, approvedBy } = body;
|
||||||
|
|
||||||
if (!recommendationId || !status) {
|
if (!recommendationId || !status) {
|
||||||
|
log.error("Missing required fields", {
|
||||||
|
recommendationId,
|
||||||
|
status,
|
||||||
|
receivedBody: body,
|
||||||
|
});
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Recommendation ID and status are required" },
|
{ error: "Recommendation ID and status are required" },
|
||||||
{ status: 400 }
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -22,28 +31,57 @@ export async function POST(req: Request) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Remove undefined keys
|
// Remove undefined keys
|
||||||
Object.keys(updates).forEach(key => updates[key] === undefined && delete updates[key]);
|
Object.keys(updates).forEach(
|
||||||
|
(key) => updates[key] === undefined && delete updates[key],
|
||||||
|
);
|
||||||
|
|
||||||
const updatedRecommendation = await db.updateRecommendation(recommendationId, updates);
|
const updatedRecommendation = await db.updateRecommendation(
|
||||||
|
recommendationId,
|
||||||
|
updates,
|
||||||
|
);
|
||||||
|
|
||||||
if (!updatedRecommendation) {
|
if (!updatedRecommendation) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Recommendation not found" },
|
{ error: "Recommendation not found" },
|
||||||
{ status: 404 }
|
{ status: 404 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If approved, create a notification for the user
|
// If approved, create a notification for the user
|
||||||
// Note: IDatabase doesn't have createNotification yet, so we'll skip it for now
|
if (status === "approved") {
|
||||||
// or we need to add it to IDatabase/SQLiteDatabase
|
try {
|
||||||
// For now, let's assume the notification is handled elsewhere or add it later
|
await db.createNotification({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
userId: updatedRecommendation.userId,
|
||||||
|
title: "Recommendation Approved! 🎉",
|
||||||
|
message:
|
||||||
|
"Your AI-powered fitness recommendation has been approved by your trainer. Check it out now!",
|
||||||
|
type: "system",
|
||||||
|
read: false,
|
||||||
|
});
|
||||||
|
|
||||||
return NextResponse.json(updatedRecommendation);
|
log.info("Notification created for approved recommendation", {
|
||||||
|
recommendationId,
|
||||||
|
userId: updatedRecommendation.userId,
|
||||||
|
});
|
||||||
|
} catch (notificationError) {
|
||||||
|
// Log error but don't fail the approval
|
||||||
|
log.error("Failed to create notification", notificationError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: updatedRecommendation,
|
||||||
|
meta: {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error approving recommendation:", error);
|
log.error("Error approving recommendation", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Internal server error" },
|
{ error: "Internal server error" },
|
||||||
{ status: 500 }
|
{ status: 500 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,18 +15,27 @@ export async function POST(req: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.debug("Generating recommendation for user", {
|
||||||
|
userId,
|
||||||
|
modelProvider,
|
||||||
|
useExternalModel,
|
||||||
|
});
|
||||||
|
|
||||||
const db = await getDatabase();
|
const db = await getDatabase();
|
||||||
|
|
||||||
// Fetch fitness profile
|
// Fetch fitness profile
|
||||||
const profile = await db.getFitnessProfileByUserId(userId);
|
const profile = await db.getFitnessProfileByUserId(userId);
|
||||||
|
|
||||||
if (!profile) {
|
if (!profile) {
|
||||||
|
log.error("Fitness profile not found", undefined, { userId });
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Fitness profile not found for this user" },
|
{ error: "Fitness profile not found for this user" },
|
||||||
{ status: 404 },
|
{ status: 404 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.debug("Fitness profile found", { profileId: profile.id });
|
||||||
|
|
||||||
// Build AI context with goals and recommendations
|
// Build AI context with goals and recommendations
|
||||||
let prompt: string;
|
let prompt: string;
|
||||||
try {
|
try {
|
||||||
@ -270,21 +279,45 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Save to database
|
// Save to database
|
||||||
|
log.debug("Saving recommendation to database", {
|
||||||
|
userId,
|
||||||
|
profileId: profile.id,
|
||||||
|
hasRecommendationText: !!parsedResponse.recommendationText,
|
||||||
|
hasActivityPlan: !!parsedResponse.activityPlan,
|
||||||
|
hasDietPlan: !!parsedResponse.dietPlan,
|
||||||
|
});
|
||||||
|
|
||||||
const recommendation = await db.createRecommendation({
|
const recommendation = await db.createRecommendation({
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
userId,
|
userId,
|
||||||
fitnessProfileId: profile.id,
|
fitnessProfileId: profile.id,
|
||||||
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(),
|
generatedAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json(recommendation);
|
log.info("Recommendation generated successfully", {
|
||||||
|
recommendationId: recommendation.id,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return in standardized format
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: recommendation,
|
||||||
|
meta: {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
requestId: `req_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error("Failed to generate recommendation", error);
|
log.error("Failed to generate recommendation", error, {
|
||||||
|
errorMessage: error instanceof Error ? error.message : String(error),
|
||||||
|
errorStack: error instanceof Error ? error.stack : undefined,
|
||||||
|
});
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Internal server error" },
|
{ error: "Internal server error" },
|
||||||
{ status: 500 },
|
{ status: 500 },
|
||||||
|
|||||||
@ -28,7 +28,7 @@ interface AttendanceStats {
|
|||||||
thisMonth: number;
|
thisMonth: number;
|
||||||
recentCheckIns: Array<{
|
recentCheckIns: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
checkInTime: string;
|
checkInTime: string; // ISO string
|
||||||
checkOutTime: string | null;
|
checkOutTime: string | null;
|
||||||
type: string;
|
type: string;
|
||||||
duration?: number; // in minutes
|
duration?: number; // in minutes
|
||||||
@ -134,8 +134,8 @@ export async function GET(request: NextRequest) {
|
|||||||
)
|
)
|
||||||
.all(userId) as Array<{
|
.all(userId) as Array<{
|
||||||
id: string;
|
id: string;
|
||||||
checkInTime: string;
|
checkInTime: string | number; // Can be ISO string or Unix timestamp
|
||||||
checkOutTime: string | null;
|
checkOutTime: string | number | null;
|
||||||
type: string;
|
type: string;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
@ -144,13 +144,33 @@ export async function GET(request: NextRequest) {
|
|||||||
// Get recent check-ins (last 10)
|
// Get recent check-ins (last 10)
|
||||||
const recentCheckIns = attendanceRecords.slice(0, 10).map((record) => {
|
const recentCheckIns = attendanceRecords.slice(0, 10).map((record) => {
|
||||||
let duration: number | undefined;
|
let duration: number | undefined;
|
||||||
|
|
||||||
|
// Convert Unix timestamps to milliseconds for calculation
|
||||||
|
const checkInMs =
|
||||||
|
typeof record.checkInTime === "number"
|
||||||
|
? record.checkInTime * 1000
|
||||||
|
: new Date(record.checkInTime).getTime();
|
||||||
|
|
||||||
if (record.checkOutTime) {
|
if (record.checkOutTime) {
|
||||||
const checkIn = new Date(record.checkInTime).getTime();
|
const checkOutMs =
|
||||||
const checkOut = new Date(record.checkOutTime).getTime();
|
typeof record.checkOutTime === "number"
|
||||||
duration = Math.round((checkOut - checkIn) / (1000 * 60)); // minutes
|
? record.checkOutTime * 1000
|
||||||
|
: new Date(record.checkOutTime).getTime();
|
||||||
|
duration = Math.round((checkOutMs - checkInMs) / (1000 * 60)); // minutes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Return with ISO string timestamps for consistency
|
||||||
return {
|
return {
|
||||||
...record,
|
id: record.id,
|
||||||
|
checkInTime: new Date(checkInMs).toISOString(),
|
||||||
|
checkOutTime: record.checkOutTime
|
||||||
|
? new Date(
|
||||||
|
typeof record.checkOutTime === "number"
|
||||||
|
? record.checkOutTime * 1000
|
||||||
|
: record.checkOutTime,
|
||||||
|
).toISOString()
|
||||||
|
: null,
|
||||||
|
type: record.type,
|
||||||
duration,
|
duration,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@ -161,15 +181,25 @@ export async function GET(request: NextRequest) {
|
|||||||
startOfWeek.setDate(now.getDate() - now.getDay()); // Sunday
|
startOfWeek.setDate(now.getDate() - now.getDay()); // Sunday
|
||||||
startOfWeek.setHours(0, 0, 0, 0);
|
startOfWeek.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
const thisWeekCheckIns = attendanceRecords.filter(
|
const thisWeekCheckIns = attendanceRecords.filter((r) => {
|
||||||
(r) => new Date(r.checkInTime) >= startOfWeek,
|
// checkInTime is Unix timestamp in seconds, convert to milliseconds
|
||||||
).length;
|
const checkInDate =
|
||||||
|
typeof r.checkInTime === "number"
|
||||||
|
? new Date(r.checkInTime * 1000)
|
||||||
|
: new Date(r.checkInTime);
|
||||||
|
return checkInDate >= startOfWeek;
|
||||||
|
}).length;
|
||||||
|
|
||||||
// Calculate this month's check-ins
|
// Calculate this month's check-ins
|
||||||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
const thisMonthCheckIns = attendanceRecords.filter(
|
const thisMonthCheckIns = attendanceRecords.filter((r) => {
|
||||||
(r) => new Date(r.checkInTime) >= startOfMonth,
|
// checkInTime is Unix timestamp in seconds, convert to milliseconds
|
||||||
).length;
|
const checkInDate =
|
||||||
|
typeof r.checkInTime === "number"
|
||||||
|
? new Date(r.checkInTime * 1000)
|
||||||
|
: new Date(r.checkInTime);
|
||||||
|
return checkInDate >= startOfMonth;
|
||||||
|
}).length;
|
||||||
|
|
||||||
// Calculate current streak (consecutive days with check-ins)
|
// Calculate current streak (consecutive days with check-ins)
|
||||||
let currentStreak = 0;
|
let currentStreak = 0;
|
||||||
@ -177,14 +207,24 @@ export async function GET(request: NextRequest) {
|
|||||||
let tempStreak = 0;
|
let tempStreak = 0;
|
||||||
let lastDate: Date | null = null;
|
let lastDate: Date | null = null;
|
||||||
|
|
||||||
const sortedRecords = [...attendanceRecords].sort(
|
const sortedRecords = [...attendanceRecords].sort((a, b) => {
|
||||||
(a, b) =>
|
const dateA =
|
||||||
new Date(b.checkInTime).getTime() - new Date(a.checkInTime).getTime(),
|
typeof a.checkInTime === "number"
|
||||||
);
|
? new Date(a.checkInTime * 1000)
|
||||||
|
: new Date(a.checkInTime);
|
||||||
|
const dateB =
|
||||||
|
typeof b.checkInTime === "number"
|
||||||
|
? new Date(b.checkInTime * 1000)
|
||||||
|
: new Date(b.checkInTime);
|
||||||
|
return dateB.getTime() - dateA.getTime();
|
||||||
|
});
|
||||||
|
|
||||||
const uniqueDays = new Set<string>();
|
const uniqueDays = new Set<string>();
|
||||||
sortedRecords.forEach((record) => {
|
sortedRecords.forEach((record) => {
|
||||||
const date = new Date(record.checkInTime);
|
const date =
|
||||||
|
typeof record.checkInTime === "number"
|
||||||
|
? new Date(record.checkInTime * 1000)
|
||||||
|
: new Date(record.checkInTime);
|
||||||
const dateStr = date.toISOString().split("T")[0];
|
const dateStr = date.toISOString().split("T")[0];
|
||||||
uniqueDays.add(dateStr);
|
uniqueDays.add(dateStr);
|
||||||
});
|
});
|
||||||
@ -257,10 +297,32 @@ export async function GET(request: NextRequest) {
|
|||||||
weekEnd.setDate(weekStart.getDate() + 7);
|
weekEnd.setDate(weekStart.getDate() + 7);
|
||||||
|
|
||||||
const weekCheckIns = attendanceRecords.filter((r) => {
|
const weekCheckIns = attendanceRecords.filter((r) => {
|
||||||
const checkInDate = new Date(r.checkInTime);
|
// checkInTime is Unix timestamp in seconds, convert to milliseconds
|
||||||
|
const checkInDate =
|
||||||
|
typeof r.checkInTime === "number"
|
||||||
|
? new Date(r.checkInTime * 1000)
|
||||||
|
: new Date(r.checkInTime);
|
||||||
return checkInDate >= weekStart && checkInDate < weekEnd;
|
return checkInDate >= weekStart && checkInDate < weekEnd;
|
||||||
}).length;
|
}).length;
|
||||||
|
|
||||||
|
log.debug("Weekly calculation", {
|
||||||
|
weekIndex: i,
|
||||||
|
weekStart: weekStart.toISOString(),
|
||||||
|
weekEnd: weekEnd.toISOString(),
|
||||||
|
weekCheckIns,
|
||||||
|
sampleRecord: attendanceRecords[0]
|
||||||
|
? {
|
||||||
|
checkInTime: attendanceRecords[0].checkInTime,
|
||||||
|
converted:
|
||||||
|
typeof attendanceRecords[0].checkInTime === "number"
|
||||||
|
? new Date(
|
||||||
|
attendanceRecords[0].checkInTime * 1000,
|
||||||
|
).toISOString()
|
||||||
|
: attendanceRecords[0].checkInTime,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
|
||||||
const weekGoalsCompleted = db
|
const weekGoalsCompleted = db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT COUNT(*) as count
|
`SELECT COUNT(*) as count
|
||||||
@ -308,14 +370,42 @@ export async function GET(request: NextRequest) {
|
|||||||
userId,
|
userId,
|
||||||
totalGoals: goalStats.total,
|
totalGoals: goalStats.total,
|
||||||
totalCheckIns,
|
totalCheckIns,
|
||||||
|
weeklyTrendLength: weeklyTrend.length,
|
||||||
|
weeklyTrendSample: weeklyTrend[0],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Return statistics in standardized format that matches mobile app expectations
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
statistics: {
|
statistics: {
|
||||||
userId,
|
userId,
|
||||||
...statistics,
|
goals: {
|
||||||
|
totalGoals: goalStats.total,
|
||||||
|
activeGoals: goalStats.active,
|
||||||
|
completedGoals: goalStats.completed,
|
||||||
|
averageProgress: goalStats.avgProgress,
|
||||||
|
goalsByType: Object.entries(goalStats.byType).map(
|
||||||
|
([goalType, count]) => ({
|
||||||
|
goalType,
|
||||||
|
count,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
attendance: {
|
||||||
|
totalCheckIns: attendanceStats.totalCheckIns,
|
||||||
|
currentStreak: attendanceStats.currentStreak,
|
||||||
|
longestStreak: attendanceStats.longestStreak,
|
||||||
|
checkInsThisWeek: attendanceStats.thisWeek,
|
||||||
|
checkInsThisMonth: attendanceStats.thisMonth,
|
||||||
|
recentCheckIns: attendanceStats.recentCheckIns,
|
||||||
|
},
|
||||||
|
weeklyTrend: weeklyTrend.map((week) => ({
|
||||||
|
weekLabel: week.week,
|
||||||
|
checkIns: week.checkIns,
|
||||||
|
goalsCompleted: week.goalsCompleted,
|
||||||
|
averageProgress: week.avgProgress,
|
||||||
|
})),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
meta: {
|
meta: {
|
||||||
@ -323,15 +413,6 @@ export async function GET(request: NextRequest) {
|
|||||||
requestId: `req_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
|
requestId: `req_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
data: { statistics },
|
|
||||||
meta: {
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
requestId: `req_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error("Failed to fetch statistics", error);
|
log.error("Failed to fetch statistics", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@ -83,7 +83,10 @@ export function Recommendations({ userId }: RecommendationsProps) {
|
|||||||
const response = await fetch("/api/recommendations/approve", {
|
const response = await fetch("/api/recommendations/approve", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ recommendationId }),
|
body: JSON.stringify({
|
||||||
|
recommendationId,
|
||||||
|
status: "approved",
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import {
|
|||||||
Attendance,
|
Attendance,
|
||||||
Recommendation,
|
Recommendation,
|
||||||
FitnessGoal,
|
FitnessGoal,
|
||||||
|
Notification,
|
||||||
DatabaseConfig,
|
DatabaseConfig,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import {
|
import {
|
||||||
@ -16,6 +17,7 @@ import {
|
|||||||
attendance,
|
attendance,
|
||||||
recommendations,
|
recommendations,
|
||||||
fitnessGoals,
|
fitnessGoals,
|
||||||
|
notifications,
|
||||||
eq,
|
eq,
|
||||||
and,
|
and,
|
||||||
desc,
|
desc,
|
||||||
@ -1419,4 +1421,94 @@ export class DrizzleDatabase implements IDatabase {
|
|||||||
updatedAt: new Date(row.updatedAt as number | Date),
|
updatedAt: new Date(row.updatedAt as number | Date),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Notification operations
|
||||||
|
async createNotification(
|
||||||
|
notification: Omit<Notification, "createdAt">,
|
||||||
|
): Promise<Notification> {
|
||||||
|
const newNotification = {
|
||||||
|
...notification,
|
||||||
|
createdAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await this.db.insert(notifications).values(newNotification);
|
||||||
|
|
||||||
|
log.debug("Notification created", { id: notification.id });
|
||||||
|
return newNotification;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getNotificationsByUserId(userId: string): Promise<Notification[]> {
|
||||||
|
const rows = await this.db
|
||||||
|
.select()
|
||||||
|
.from(notifications)
|
||||||
|
.where(eq(notifications.userId, userId))
|
||||||
|
.orderBy(desc(notifications.createdAt))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
return rows.map((row) => this.mapNotification(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUnreadNotificationCount(userId: string): Promise<number> {
|
||||||
|
const result = await this.db
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(notifications)
|
||||||
|
.where(
|
||||||
|
and(eq(notifications.userId, userId), eq(notifications.read, false)),
|
||||||
|
);
|
||||||
|
|
||||||
|
return Number(result[0]?.count || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async markNotificationAsRead(id: string): Promise<Notification | null> {
|
||||||
|
await this.db
|
||||||
|
.update(notifications)
|
||||||
|
.set({ read: true })
|
||||||
|
.where(eq(notifications.id, id));
|
||||||
|
|
||||||
|
const rows = await this.db
|
||||||
|
.select()
|
||||||
|
.from(notifications)
|
||||||
|
.where(eq(notifications.id, id));
|
||||||
|
|
||||||
|
if (rows.length === 0) return null;
|
||||||
|
|
||||||
|
return this.mapNotification(rows[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async markAllNotificationsAsRead(userId: string): Promise<void> {
|
||||||
|
await this.db
|
||||||
|
.update(notifications)
|
||||||
|
.set({ read: true })
|
||||||
|
.where(
|
||||||
|
and(eq(notifications.userId, userId), eq(notifications.read, false)),
|
||||||
|
);
|
||||||
|
|
||||||
|
log.debug("All notifications marked as read", { userId });
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteNotification(id: string): Promise<boolean> {
|
||||||
|
const result = await this.db
|
||||||
|
.delete(notifications)
|
||||||
|
.where(eq(notifications.id, id));
|
||||||
|
|
||||||
|
return result.changes > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapNotification(row: Record<string, unknown>): Notification {
|
||||||
|
const createdAtValue = row.createdAt as number | Date;
|
||||||
|
const createdAt =
|
||||||
|
typeof createdAtValue === "number"
|
||||||
|
? new Date(createdAtValue * 1000) // SQLite stores in seconds, convert to milliseconds
|
||||||
|
: createdAtValue;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: String(row.id),
|
||||||
|
userId: String(row.userId || row.user_id),
|
||||||
|
title: String(row.title),
|
||||||
|
message: String(row.message),
|
||||||
|
type: String(row.type) as Notification["type"],
|
||||||
|
read: Boolean(row.read),
|
||||||
|
createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import {
|
|||||||
Attendance,
|
Attendance,
|
||||||
Recommendation,
|
Recommendation,
|
||||||
FitnessGoal,
|
FitnessGoal,
|
||||||
|
Notification,
|
||||||
} from "@fitai/shared";
|
} from "@fitai/shared";
|
||||||
import type { SortConfig, FilterCondition } from "../filtering";
|
import type { SortConfig, FilterCondition } from "../filtering";
|
||||||
|
|
||||||
@ -15,7 +16,14 @@ export interface User extends SharedUser {
|
|||||||
password: string;
|
password: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type { Client, FitnessProfile, Attendance, Recommendation, FitnessGoal };
|
export type {
|
||||||
|
Client,
|
||||||
|
FitnessProfile,
|
||||||
|
Attendance,
|
||||||
|
Recommendation,
|
||||||
|
FitnessGoal,
|
||||||
|
Notification,
|
||||||
|
};
|
||||||
|
|
||||||
// Database Interface - allows us to swap implementations
|
// Database Interface - allows us to swap implementations
|
||||||
export interface IDatabase {
|
export interface IDatabase {
|
||||||
@ -163,6 +171,16 @@ export interface IDatabase {
|
|||||||
): Promise<FitnessGoal | null>;
|
): Promise<FitnessGoal | null>;
|
||||||
completeGoal(id: string): Promise<FitnessGoal | null>;
|
completeGoal(id: string): Promise<FitnessGoal | null>;
|
||||||
|
|
||||||
|
// Notification operations
|
||||||
|
createNotification(
|
||||||
|
notification: Omit<Notification, "createdAt">,
|
||||||
|
): Promise<Notification>;
|
||||||
|
getNotificationsByUserId(userId: string): Promise<Notification[]>;
|
||||||
|
getUnreadNotificationCount(userId: string): Promise<number>;
|
||||||
|
markNotificationAsRead(id: string): Promise<Notification | null>;
|
||||||
|
markAllNotificationsAsRead(userId: string): Promise<void>;
|
||||||
|
deleteNotification(id: string): Promise<boolean>;
|
||||||
|
|
||||||
// Dashboard operations
|
// Dashboard operations
|
||||||
getDashboardStats(): Promise<{
|
getDashboardStats(): Promise<{
|
||||||
totalUsers: number;
|
totalUsers: number;
|
||||||
|
|||||||
@ -83,7 +83,7 @@ export const prioritySchema = z.enum(["low", "medium", "high"]);
|
|||||||
export const goalStatusSchema = z.enum(["active", "completed", "abandoned"]);
|
export const goalStatusSchema = z.enum(["active", "completed", "abandoned"]);
|
||||||
|
|
||||||
export const fitnessGoalSchema = z.object({
|
export const fitnessGoalSchema = z.object({
|
||||||
userId: z.string().min(1, "User ID is required"),
|
userId: z.string().min(1, "User ID is required").optional(), // Optional for authenticated requests
|
||||||
goalType: goalTypeSchema,
|
goalType: goalTypeSchema,
|
||||||
title: z.string().min(1, "Title is required").max(100),
|
title: z.string().min(1, "Title is required").max(100),
|
||||||
description: z.string().max(500).optional(),
|
description: z.string().max(500).optional(),
|
||||||
|
|||||||
@ -11,13 +11,12 @@
|
|||||||
"resizeMode": "contain",
|
"resizeMode": "contain",
|
||||||
"backgroundColor": "#ffffff"
|
"backgroundColor": "#ffffff"
|
||||||
},
|
},
|
||||||
"assetBundlePatterns": [
|
"assetBundlePatterns": ["**/*"],
|
||||||
"**/*"
|
|
||||||
],
|
|
||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": true,
|
"supportsTablet": true,
|
||||||
"infoPlist": {
|
"infoPlist": {
|
||||||
"NSCameraUsageDescription": "This app uses the camera to scan food barcodes and identify nutritional information."
|
"NSCameraUsageDescription": "This app uses the camera to scan food barcodes and identify nutritional information.",
|
||||||
|
"NSUserNotificationsUsageDescription": "This app uses notifications to keep you updated on your fitness progress, recommendation approvals, and important reminders."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
@ -25,9 +24,7 @@
|
|||||||
"foregroundImage": "./assets/adaptive-icon.png",
|
"foregroundImage": "./assets/adaptive-icon.png",
|
||||||
"backgroundColor": "#ffffff"
|
"backgroundColor": "#ffffff"
|
||||||
},
|
},
|
||||||
"permissions": [
|
"permissions": ["CAMERA", "POST_NOTIFICATIONS"]
|
||||||
"CAMERA"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"web": {
|
"web": {
|
||||||
"favicon": "./assets/favicon.png"
|
"favicon": "./assets/favicon.png"
|
||||||
@ -35,7 +32,15 @@
|
|||||||
"plugins": [
|
"plugins": [
|
||||||
"expo-router",
|
"expo-router",
|
||||||
"expo-font",
|
"expo-font",
|
||||||
"expo-barcode-scanner"
|
"expo-barcode-scanner",
|
||||||
|
[
|
||||||
|
"expo-notifications",
|
||||||
|
{
|
||||||
|
"icon": "./assets/icon.png",
|
||||||
|
"color": "#ffffff",
|
||||||
|
"sounds": []
|
||||||
|
}
|
||||||
|
]
|
||||||
],
|
],
|
||||||
"scheme": "fitai"
|
"scheme": "fitai"
|
||||||
}
|
}
|
||||||
|
|||||||
39
apps/mobile/package-lock.json
generated
39
apps/mobile/package-lock.json
generated
@ -24,6 +24,7 @@
|
|||||||
"expo-camera": "~17.0.9",
|
"expo-camera": "~17.0.9",
|
||||||
"expo-constants": "^18.0.10",
|
"expo-constants": "^18.0.10",
|
||||||
"expo-crypto": "^15.0.8",
|
"expo-crypto": "^15.0.8",
|
||||||
|
"expo-device": "~8.0.10",
|
||||||
"expo-font": "~14.0.9",
|
"expo-font": "~14.0.9",
|
||||||
"expo-haptics": "^15.0.7",
|
"expo-haptics": "^15.0.7",
|
||||||
"expo-linear-gradient": "~15.0.7",
|
"expo-linear-gradient": "~15.0.7",
|
||||||
@ -7357,6 +7358,44 @@
|
|||||||
"expo": "*"
|
"expo": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expo-device": {
|
||||||
|
"version": "8.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-device/-/expo-device-8.0.10.tgz",
|
||||||
|
"integrity": "sha512-jd5BxjaF7382JkDMaC+P04aXXknB2UhWaVx5WiQKA05ugm/8GH5uaz9P9ckWdMKZGQVVEOC8MHaUADoT26KmFA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ua-parser-js": "^0.7.33"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/expo-device/node_modules/ua-parser-js": {
|
||||||
|
"version": "0.7.41",
|
||||||
|
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.41.tgz",
|
||||||
|
"integrity": "sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/ua-parser-js"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "paypal",
|
||||||
|
"url": "https://paypal.me/faisalman"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/faisalman"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"ua-parser-js": "script/cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/expo-file-system": {
|
"node_modules/expo-file-system": {
|
||||||
"version": "19.0.19",
|
"version": "19.0.19",
|
||||||
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.19.tgz",
|
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.19.tgz",
|
||||||
|
|||||||
@ -30,6 +30,7 @@
|
|||||||
"expo-camera": "~17.0.9",
|
"expo-camera": "~17.0.9",
|
||||||
"expo-constants": "^18.0.10",
|
"expo-constants": "^18.0.10",
|
||||||
"expo-crypto": "^15.0.8",
|
"expo-crypto": "^15.0.8",
|
||||||
|
"expo-device": "~8.0.10",
|
||||||
"expo-font": "~14.0.9",
|
"expo-font": "~14.0.9",
|
||||||
"expo-haptics": "^15.0.7",
|
"expo-haptics": "^15.0.7",
|
||||||
"expo-linear-gradient": "~15.0.7",
|
"expo-linear-gradient": "~15.0.7",
|
||||||
|
|||||||
209
apps/mobile/src/api/notifications.ts
Normal file
209
apps/mobile/src/api/notifications.ts
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
import { API_BASE_URL } from "../config/api";
|
||||||
|
|
||||||
|
export interface Notification {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
type: "payment_reminder" | "attendance" | "promotion" | "system";
|
||||||
|
read: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiResponse<T> {
|
||||||
|
success: boolean;
|
||||||
|
data: T;
|
||||||
|
meta?: {
|
||||||
|
timestamp: string;
|
||||||
|
count?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all notifications for the authenticated user
|
||||||
|
*/
|
||||||
|
export async function fetchNotifications(
|
||||||
|
token: string | null,
|
||||||
|
): Promise<Notification[]> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
headers["Authorization"] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/notifications`, {
|
||||||
|
method: "GET",
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(
|
||||||
|
`Failed to fetch notifications: ${response.status} - ${errorText}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: ApiResponse<Notification[]> = await response.json();
|
||||||
|
|
||||||
|
if (result.success && result.data) {
|
||||||
|
// Convert date strings to Date objects
|
||||||
|
return result.data.map((notification) => ({
|
||||||
|
...notification,
|
||||||
|
createdAt: new Date(notification.createdAt),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get unread notification count
|
||||||
|
*/
|
||||||
|
export async function fetchUnreadCount(token: string | null): Promise<number> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
headers["Authorization"] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`${API_BASE_URL}/api/notifications/unread-count`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
headers,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch unread count: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: ApiResponse<{ count: number }> = await response.json();
|
||||||
|
|
||||||
|
return result.success && result.data ? result.data.count : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a notification as read
|
||||||
|
*/
|
||||||
|
export async function markAsRead(
|
||||||
|
notificationId: string,
|
||||||
|
token: string | null,
|
||||||
|
): Promise<Notification> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
headers["Authorization"] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`${API_BASE_URL}/api/notifications/${notificationId}`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to mark notification as read: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: ApiResponse<Notification> = await response.json();
|
||||||
|
|
||||||
|
if (result.success && result.data) {
|
||||||
|
return {
|
||||||
|
...result.data,
|
||||||
|
createdAt: new Date(result.data.createdAt),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Invalid response format");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark all notifications as read
|
||||||
|
*/
|
||||||
|
export async function markAllAsRead(token: string | null): Promise<void> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
headers["Authorization"] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`${API_BASE_URL}/api/notifications/mark-all-read`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to mark all notifications as read: ${response.status}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a notification
|
||||||
|
*/
|
||||||
|
export async function deleteNotification(
|
||||||
|
notificationId: string,
|
||||||
|
token: string | null,
|
||||||
|
): Promise<void> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
headers["Authorization"] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`${API_BASE_URL}/api/notifications/${notificationId}`,
|
||||||
|
{
|
||||||
|
method: "DELETE",
|
||||||
|
headers,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to delete notification: ${response.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save Expo push notification token
|
||||||
|
*/
|
||||||
|
export async function savePushToken(
|
||||||
|
expoPushToken: string,
|
||||||
|
deviceType: "ios" | "android",
|
||||||
|
token: string | null,
|
||||||
|
): Promise<void> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
headers["Authorization"] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/notifications/save-token`, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ expoPushToken, deviceType }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to save push token: ${response.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -109,11 +109,13 @@ export async function generateRecommendation(
|
|||||||
*
|
*
|
||||||
* @param recommendationId - Recommendation ID
|
* @param recommendationId - Recommendation ID
|
||||||
* @param token - Auth token
|
* @param token - Auth token
|
||||||
|
* @param approvedBy - User ID of the approver (optional)
|
||||||
* @returns The approved recommendation
|
* @returns The approved recommendation
|
||||||
*/
|
*/
|
||||||
export async function approveRecommendation(
|
export async function approveRecommendation(
|
||||||
recommendationId: string,
|
recommendationId: string,
|
||||||
token: string | null,
|
token: string | null,
|
||||||
|
approvedBy?: string,
|
||||||
): Promise<Recommendation> {
|
): Promise<Recommendation> {
|
||||||
const headers: any = {
|
const headers: any = {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
@ -128,7 +130,11 @@ export async function approveRecommendation(
|
|||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers,
|
headers,
|
||||||
body: JSON.stringify({ id: recommendationId }),
|
body: JSON.stringify({
|
||||||
|
recommendationId,
|
||||||
|
status: "approved",
|
||||||
|
approvedBy,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -13,6 +13,7 @@ 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 { AttendanceCalendar } from "../../components/AttendanceCalendar";
|
||||||
|
import { useStatistics } from "../../contexts/StatisticsContext";
|
||||||
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";
|
||||||
@ -20,6 +21,7 @@ import log from "../../utils/logger";
|
|||||||
|
|
||||||
export default function AttendanceScreen() {
|
export default function AttendanceScreen() {
|
||||||
const { getToken, userId } = useAuth();
|
const { getToken, userId } = useAuth();
|
||||||
|
const { clearCache: clearStatisticsCache } = useStatistics();
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [activeCheckIn, setActiveCheckIn] = useState<Attendance | null>(null);
|
const [activeCheckIn, setActiveCheckIn] = useState<Attendance | null>(null);
|
||||||
const [history, setHistory] = useState<Attendance[]>([]);
|
const [history, setHistory] = useState<Attendance[]>([]);
|
||||||
@ -80,6 +82,10 @@ export default function AttendanceScreen() {
|
|||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|
||||||
await attendanceApi.checkIn("gym", token);
|
await attendanceApi.checkIn("gym", token);
|
||||||
|
|
||||||
|
// Clear statistics cache to force refresh on home screen
|
||||||
|
clearStatisticsCache();
|
||||||
|
|
||||||
fetchAttendance();
|
fetchAttendance();
|
||||||
Alert.alert("Success", "Checked in successfully!");
|
Alert.alert("Success", "Checked in successfully!");
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
@ -94,6 +100,10 @@ export default function AttendanceScreen() {
|
|||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|
||||||
await attendanceApi.checkOut(token);
|
await attendanceApi.checkOut(token);
|
||||||
|
|
||||||
|
// Clear statistics cache to force refresh on home screen
|
||||||
|
clearStatisticsCache();
|
||||||
|
|
||||||
fetchAttendance();
|
fetchAttendance();
|
||||||
Alert.alert("Success", "Checked out successfully!");
|
Alert.alert("Success", "Checked out successfully!");
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import {
|
|||||||
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 { useFocusEffect } from "@react-navigation/native";
|
||||||
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";
|
||||||
@ -16,12 +17,18 @@ 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 { NutritionWidget } from "../../components/NutritionWidget";
|
||||||
import { WeeklyProgressWidget } from "../../components/WeeklyProgressWidget";
|
import { WeeklyProgressWidget } from "../../components/WeeklyProgressWidget";
|
||||||
import { ScanFoodModal } from "../../components/ScanFoodModal";
|
import { ScanFoodModal } from "../../components/ScanFoodModal";
|
||||||
|
import { useStatistics } from "../../contexts/StatisticsContext";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
|
||||||
|
const CALORIE_GOAL = 2000; // kcal
|
||||||
|
const WATER_GOAL = 2000; // ml
|
||||||
|
|
||||||
export default function HomeScreen() {
|
export default function HomeScreen() {
|
||||||
const { user } = useUser();
|
const { user } = useUser();
|
||||||
|
const { refetchStatistics, forceRefresh } = useStatistics();
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const [trackMealModalVisible, setTrackMealModalVisible] = useState(false);
|
const [trackMealModalVisible, setTrackMealModalVisible] = useState(false);
|
||||||
const [addWaterModalVisible, setAddWaterModalVisible] = useState(false);
|
const [addWaterModalVisible, setAddWaterModalVisible] = useState(false);
|
||||||
@ -29,12 +36,19 @@ export default function HomeScreen() {
|
|||||||
const [calories, setCalories] = useState(0);
|
const [calories, setCalories] = useState(0);
|
||||||
const [waterIntake, setWaterIntake] = useState(0);
|
const [waterIntake, setWaterIntake] = useState(0);
|
||||||
|
|
||||||
const onRefresh = useCallback(() => {
|
// Refetch statistics when screen comes into focus
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
refetchStatistics();
|
||||||
|
}, [refetchStatistics]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const onRefresh = useCallback(async () => {
|
||||||
setRefreshing(true);
|
setRefreshing(true);
|
||||||
setTimeout(() => {
|
// Force refetch statistics bypassing cache
|
||||||
|
await forceRefresh();
|
||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
}, 2000);
|
}, [forceRefresh]);
|
||||||
}, []);
|
|
||||||
|
|
||||||
const getGreeting = () => {
|
const getGreeting = () => {
|
||||||
const hour = new Date().getHours();
|
const hour = new Date().getHours();
|
||||||
@ -75,8 +89,48 @@ export default function HomeScreen() {
|
|||||||
setWaterIntake(0);
|
setWaterIntake(0);
|
||||||
const today = new Date().toDateString();
|
const today = new Date().toDateString();
|
||||||
await AsyncStorage.setItem("lastResetDate", today);
|
await AsyncStorage.setItem("lastResetDate", today);
|
||||||
|
await AsyncStorage.removeItem(`calories_${today}`);
|
||||||
|
await AsyncStorage.removeItem(`water_${today}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Load persisted data on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const loadPersistedData = async () => {
|
||||||
|
const today = new Date().toDateString();
|
||||||
|
const storedCalories = await AsyncStorage.getItem(`calories_${today}`);
|
||||||
|
const storedWater = await AsyncStorage.getItem(`water_${today}`);
|
||||||
|
|
||||||
|
if (storedCalories) {
|
||||||
|
setCalories(parseInt(storedCalories, 10));
|
||||||
|
}
|
||||||
|
if (storedWater) {
|
||||||
|
setWaterIntake(parseInt(storedWater, 10));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadPersistedData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Persist calories to AsyncStorage whenever it changes
|
||||||
|
useEffect(() => {
|
||||||
|
const persistCalories = async () => {
|
||||||
|
const today = new Date().toDateString();
|
||||||
|
await AsyncStorage.setItem(`calories_${today}`, calories.toString());
|
||||||
|
};
|
||||||
|
|
||||||
|
persistCalories();
|
||||||
|
}, [calories]);
|
||||||
|
|
||||||
|
// Persist water intake to AsyncStorage whenever it changes
|
||||||
|
useEffect(() => {
|
||||||
|
const persistWater = async () => {
|
||||||
|
const today = new Date().toDateString();
|
||||||
|
await AsyncStorage.setItem(`water_${today}`, waterIntake.toString());
|
||||||
|
};
|
||||||
|
|
||||||
|
persistWater();
|
||||||
|
}, [waterIntake]);
|
||||||
|
|
||||||
// Check for midnight reset
|
// Check for midnight reset
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkAndResetIfNeeded = async () => {
|
const checkAndResetIfNeeded = async () => {
|
||||||
@ -146,6 +200,26 @@ export default function HomeScreen() {
|
|||||||
{/* Activity Widget */}
|
{/* Activity Widget */}
|
||||||
<ActivityWidget calories={calories} />
|
<ActivityWidget calories={calories} />
|
||||||
|
|
||||||
|
{/* Quick Action Grid */}
|
||||||
|
<QuickActionGrid
|
||||||
|
onTrackMealPress={() => setTrackMealModalVisible(true)}
|
||||||
|
onAddWaterPress={() => setAddWaterModalVisible(true)}
|
||||||
|
onScanFoodPress={() => setScanFoodModalVisible(true)}
|
||||||
|
onLogWorkoutPress={() => {
|
||||||
|
// TODO: Implement workout logging
|
||||||
|
console.log("Log workout tapped");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Nutrition Widget */}
|
||||||
|
<NutritionWidget current={calories} goal={CALORIE_GOAL} />
|
||||||
|
|
||||||
|
{/* Hydration Widget */}
|
||||||
|
<HydrationWidget current={waterIntake} goal={WATER_GOAL} />
|
||||||
|
|
||||||
|
{/* Weekly Progress Widget */}
|
||||||
|
<WeeklyProgressWidget />
|
||||||
|
|
||||||
<TrackMealModal
|
<TrackMealModal
|
||||||
visible={trackMealModalVisible}
|
visible={trackMealModalVisible}
|
||||||
onClose={() => setTrackMealModalVisible(false)}
|
onClose={() => setTrackMealModalVisible(false)}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useCallback } from "react";
|
import React, { useState, useCallback, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
@ -15,6 +15,8 @@ import { useUser } from "@clerk/clerk-expo";
|
|||||||
import { useFocusEffect } from "expo-router";
|
import { useFocusEffect } from "expo-router";
|
||||||
import { theme } from "../../styles/theme";
|
import { theme } from "../../styles/theme";
|
||||||
import { useRecommendations } from "../../contexts/RecommendationsContext";
|
import { useRecommendations } from "../../contexts/RecommendationsContext";
|
||||||
|
import { useNotifications } from "../../contexts/NotificationsContext";
|
||||||
|
import { NotificationsModal } from "../../components/NotificationsModal";
|
||||||
import type { Recommendation } from "../../api/recommendations";
|
import type { Recommendation } from "../../api/recommendations";
|
||||||
import log from "../../utils/logger";
|
import log from "../../utils/logger";
|
||||||
|
|
||||||
@ -26,8 +28,20 @@ export default function RecommendationsScreen() {
|
|||||||
refetchRecommendations,
|
refetchRecommendations,
|
||||||
generateNewRecommendation,
|
generateNewRecommendation,
|
||||||
} = useRecommendations();
|
} = useRecommendations();
|
||||||
|
const { unreadCount, refetchNotifications } = useNotifications();
|
||||||
const [generating, setGenerating] = useState(false);
|
const [generating, setGenerating] = useState(false);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [notificationsVisible, setNotificationsVisible] = useState(false);
|
||||||
|
|
||||||
|
const handleOpenNotifications = () => {
|
||||||
|
log.debug("Opening notifications modal", { unreadCount });
|
||||||
|
setNotificationsVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseNotifications = () => {
|
||||||
|
log.debug("Closing notifications modal");
|
||||||
|
setNotificationsVisible(false);
|
||||||
|
};
|
||||||
|
|
||||||
// Filter to show only approved recommendations for regular users
|
// Filter to show only approved recommendations for regular users
|
||||||
const recommendations = allRecommendations.filter(
|
const recommendations = allRecommendations.filter(
|
||||||
@ -37,12 +51,13 @@ export default function RecommendationsScreen() {
|
|||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
refetchRecommendations();
|
refetchRecommendations();
|
||||||
}, [refetchRecommendations]),
|
refetchNotifications();
|
||||||
|
}, [refetchRecommendations, refetchNotifications]),
|
||||||
);
|
);
|
||||||
|
|
||||||
const onRefresh = async () => {
|
const onRefresh = async () => {
|
||||||
setRefreshing(true);
|
setRefreshing(true);
|
||||||
await refetchRecommendations();
|
await Promise.all([refetchRecommendations(), refetchNotifications()]);
|
||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -61,7 +76,7 @@ export default function RecommendationsScreen() {
|
|||||||
setGenerating(true);
|
setGenerating(true);
|
||||||
await generateNewRecommendation({
|
await generateNewRecommendation({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
modelProvider: "openai",
|
modelProvider: "deepseek",
|
||||||
useExternalModel: true,
|
useExternalModel: true,
|
||||||
});
|
});
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
@ -94,6 +109,10 @@ export default function RecommendationsScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
|
<NotificationsModal
|
||||||
|
visible={notificationsVisible}
|
||||||
|
onClose={handleCloseNotifications}
|
||||||
|
/>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
contentContainerStyle={styles.scrollContent}
|
contentContainerStyle={styles.scrollContent}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
@ -117,9 +136,18 @@ export default function RecommendationsScreen() {
|
|||||||
Personalized fitness & nutrition plans
|
Personalized fitness & nutrition plans
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.iconContainer}>
|
<TouchableOpacity
|
||||||
|
style={styles.iconContainer}
|
||||||
|
onPress={handleOpenNotifications}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
<Ionicons name="sparkles" size={32} color="#fff" />
|
<Ionicons name="sparkles" size={32} color="#fff" />
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<View style={styles.badge}>
|
||||||
|
<Text style={styles.badgeText}>{unreadCount}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
</LinearGradient>
|
</LinearGradient>
|
||||||
|
|
||||||
{/* Generate Button */}
|
{/* Generate Button */}
|
||||||
@ -314,6 +342,22 @@ const styles = StyleSheet.create({
|
|||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
},
|
},
|
||||||
|
badge: {
|
||||||
|
position: "absolute",
|
||||||
|
top: -4,
|
||||||
|
right: -4,
|
||||||
|
backgroundColor: theme.colors.danger,
|
||||||
|
borderRadius: 10,
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
badgeText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: "bold",
|
||||||
|
},
|
||||||
actionContainer: {
|
actionContainer: {
|
||||||
paddingHorizontal: 20,
|
paddingHorizontal: 20,
|
||||||
marginBottom: 20,
|
marginBottom: 20,
|
||||||
|
|||||||
@ -7,8 +7,26 @@ import { validateEnv } from "../utils/env";
|
|||||||
import { StatisticsProvider } from "../contexts/StatisticsContext";
|
import { StatisticsProvider } from "../contexts/StatisticsContext";
|
||||||
import { FitnessGoalsProvider } from "../contexts/FitnessGoalsContext";
|
import { FitnessGoalsProvider } from "../contexts/FitnessGoalsContext";
|
||||||
import { RecommendationsProvider } from "../contexts/RecommendationsContext";
|
import { RecommendationsProvider } from "../contexts/RecommendationsContext";
|
||||||
|
import { NotificationsProvider } from "../contexts/NotificationsContext";
|
||||||
import log from "../utils/logger";
|
import log from "../utils/logger";
|
||||||
|
|
||||||
|
// Wrapper to use notification permissions hook after ClerkLoaded
|
||||||
|
function AppContent() {
|
||||||
|
// Import here to avoid hook execution before Clerk is loaded
|
||||||
|
const {
|
||||||
|
useNotificationPermissions,
|
||||||
|
} = require("../hooks/useNotificationPermissions");
|
||||||
|
useNotificationPermissions();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Stack.Screen name="(auth)" options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen name="welcome" options={{ headerShown: false }} />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Validate environment variables on app startup
|
// Validate environment variables on app startup
|
||||||
try {
|
try {
|
||||||
const env = validateEnv();
|
const env = validateEnv();
|
||||||
@ -153,17 +171,15 @@ export default function RootLayout() {
|
|||||||
return (
|
return (
|
||||||
<ClerkProvider tokenCache={tokenCache} publishableKey={publishableKey}>
|
<ClerkProvider tokenCache={tokenCache} publishableKey={publishableKey}>
|
||||||
<ClerkLoaded>
|
<ClerkLoaded>
|
||||||
|
<NotificationsProvider>
|
||||||
<StatisticsProvider>
|
<StatisticsProvider>
|
||||||
<FitnessGoalsProvider>
|
<FitnessGoalsProvider>
|
||||||
<RecommendationsProvider>
|
<RecommendationsProvider>
|
||||||
<Stack>
|
<AppContent />
|
||||||
<Stack.Screen name="(auth)" options={{ headerShown: false }} />
|
|
||||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
|
||||||
<Stack.Screen name="welcome" options={{ headerShown: false }} />
|
|
||||||
</Stack>
|
|
||||||
</RecommendationsProvider>
|
</RecommendationsProvider>
|
||||||
</FitnessGoalsProvider>
|
</FitnessGoalsProvider>
|
||||||
</StatisticsProvider>
|
</StatisticsProvider>
|
||||||
|
</NotificationsProvider>
|
||||||
</ClerkLoaded>
|
</ClerkLoaded>
|
||||||
</ClerkProvider>
|
</ClerkProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
434
apps/mobile/src/components/NotificationsModal.tsx
Normal file
434
apps/mobile/src/components/NotificationsModal.tsx
Normal file
@ -0,0 +1,434 @@
|
|||||||
|
import React, { useEffect } from "react";
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
StyleSheet,
|
||||||
|
Modal,
|
||||||
|
TouchableOpacity,
|
||||||
|
FlatList,
|
||||||
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
|
StatusBar,
|
||||||
|
Platform,
|
||||||
|
} from "react-native";
|
||||||
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { theme } from "../styles/theme";
|
||||||
|
import { useNotifications } from "../contexts/NotificationsContext";
|
||||||
|
import type { Notification } from "../api/notifications";
|
||||||
|
import log from "../utils/logger";
|
||||||
|
|
||||||
|
interface NotificationsModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NotificationsModal({
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
}: NotificationsModalProps) {
|
||||||
|
const {
|
||||||
|
notifications,
|
||||||
|
loading,
|
||||||
|
markNotificationAsRead,
|
||||||
|
deleteNotificationAction,
|
||||||
|
markAllAsReadAction,
|
||||||
|
} = useNotifications();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
log.debug("NotificationsModal opened", {
|
||||||
|
notificationCount: notifications.length,
|
||||||
|
loading,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [visible, notifications.length, loading]);
|
||||||
|
|
||||||
|
const handleMarkAsRead = async (id: string) => {
|
||||||
|
await markNotificationAsRead(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (id: string) => {
|
||||||
|
Alert.alert("Delete Notification", "Are you sure?", [
|
||||||
|
{ text: "Cancel", style: "cancel" },
|
||||||
|
{
|
||||||
|
text: "Delete",
|
||||||
|
style: "destructive",
|
||||||
|
onPress: () => deleteNotificationAction(id),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMarkAllAsRead = () => {
|
||||||
|
if (notifications.filter((n) => !n.read).length === 0) return;
|
||||||
|
Alert.alert("Mark All as Read", "Mark all notifications as read?", [
|
||||||
|
{ text: "Cancel", style: "cancel" },
|
||||||
|
{
|
||||||
|
text: "Mark All",
|
||||||
|
onPress: () => markAllAsReadAction(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible={visible}
|
||||||
|
animationType="slide"
|
||||||
|
presentationStyle="fullScreen"
|
||||||
|
onRequestClose={onClose}
|
||||||
|
transparent={false}
|
||||||
|
statusBarTranslucent={false}
|
||||||
|
>
|
||||||
|
<View style={styles.modalWrapper}>
|
||||||
|
<StatusBar barStyle="light-content" />
|
||||||
|
<View style={styles.container}>
|
||||||
|
{/* Header */}
|
||||||
|
<LinearGradient
|
||||||
|
colors={theme.gradients.primary}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
style={styles.header}
|
||||||
|
>
|
||||||
|
<View style={styles.headerContent}>
|
||||||
|
<View>
|
||||||
|
<Text style={styles.headerTitle}>Notifications</Text>
|
||||||
|
<Text style={styles.headerSubtitle}>
|
||||||
|
{notifications.filter((n) => !n.read).length} unread
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
|
||||||
|
<Ionicons name="close" size={28} color="#fff" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</LinearGradient>
|
||||||
|
|
||||||
|
{/* Actions Bar */}
|
||||||
|
{notifications.length > 0 && (
|
||||||
|
<View style={styles.actionsBar}>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleMarkAllAsRead}
|
||||||
|
disabled={notifications.filter((n) => !n.read).length === 0}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.actionText,
|
||||||
|
notifications.filter((n) => !n.read).length === 0 &&
|
||||||
|
styles.actionTextDisabled,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Mark all as read
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Notifications List */}
|
||||||
|
{loading ? (
|
||||||
|
<View style={styles.centered}>
|
||||||
|
<ActivityIndicator size="large" color={theme.colors.primary} />
|
||||||
|
</View>
|
||||||
|
) : notifications.length === 0 ? (
|
||||||
|
<View style={styles.emptyState}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={["rgba(59, 130, 246, 0.1)", "rgba(59, 130, 246, 0.05)"]}
|
||||||
|
style={styles.emptyCard}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name="notifications-off-outline"
|
||||||
|
size={64}
|
||||||
|
color={theme.colors.primary}
|
||||||
|
/>
|
||||||
|
<Text style={styles.emptyTitle}>No Notifications</Text>
|
||||||
|
<Text style={styles.emptyText}>
|
||||||
|
You're all caught up! New notifications will appear here.
|
||||||
|
</Text>
|
||||||
|
</LinearGradient>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={notifications}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<NotificationItem
|
||||||
|
notification={item}
|
||||||
|
onMarkAsRead={handleMarkAsRead}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
contentContainerStyle={styles.listContent}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NotificationItemProps {
|
||||||
|
notification: Notification;
|
||||||
|
onMarkAsRead: (id: string) => void;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function NotificationItem({
|
||||||
|
notification,
|
||||||
|
onMarkAsRead,
|
||||||
|
onDelete,
|
||||||
|
}: NotificationItemProps) {
|
||||||
|
const getIcon = (type: Notification["type"]) => {
|
||||||
|
switch (type) {
|
||||||
|
case "payment_reminder":
|
||||||
|
return "card-outline";
|
||||||
|
case "attendance":
|
||||||
|
return "checkmark-circle-outline";
|
||||||
|
case "promotion":
|
||||||
|
return "megaphone-outline";
|
||||||
|
case "system":
|
||||||
|
default:
|
||||||
|
return "information-circle-outline";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getIconColor = (type: Notification["type"]) => {
|
||||||
|
switch (type) {
|
||||||
|
case "payment_reminder":
|
||||||
|
return theme.colors.warning;
|
||||||
|
case "attendance":
|
||||||
|
return theme.colors.success;
|
||||||
|
case "promotion":
|
||||||
|
return theme.colors.purple;
|
||||||
|
case "system":
|
||||||
|
default:
|
||||||
|
return theme.colors.primary;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (date: Date) => {
|
||||||
|
const now = new Date();
|
||||||
|
const diff = now.getTime() - new Date(date).getTime();
|
||||||
|
const minutes = Math.floor(diff / 60000);
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
|
||||||
|
if (minutes < 1) return "Just now";
|
||||||
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
|
if (hours < 24) return `${hours}h ago`;
|
||||||
|
if (days < 7) return `${days}d ago`;
|
||||||
|
return new Date(date).toLocaleDateString();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => !notification.read && onMarkAsRead(notification.id)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={[styles.notificationCard, !notification.read && styles.unread]}
|
||||||
|
>
|
||||||
|
<View style={styles.notificationContent}>
|
||||||
|
{/* Icon */}
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.iconCircle,
|
||||||
|
{ backgroundColor: `${getIconColor(notification.type)}20` },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={getIcon(notification.type)}
|
||||||
|
size={24}
|
||||||
|
color={getIconColor(notification.type)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<View style={styles.textContent}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.notificationTitle,
|
||||||
|
!notification.read && styles.notificationTitleUnread,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{notification.title}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.notificationMessage}>
|
||||||
|
{notification.message}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.notificationTime}>
|
||||||
|
{formatTime(notification.createdAt)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => onDelete(notification.id)}
|
||||||
|
style={styles.deleteButton}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name="trash-outline"
|
||||||
|
size={20}
|
||||||
|
color={theme.colors.gray400}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Unread Indicator */}
|
||||||
|
{!notification.read && <View style={styles.unreadDot} />}
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
modalWrapper: {
|
||||||
|
flex: 1,
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
},
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: theme.colors.background,
|
||||||
|
margin: 0,
|
||||||
|
padding: 0,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
paddingTop: Platform.OS === "android" ? 50 : 60,
|
||||||
|
paddingBottom: 20,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
borderBottomLeftRadius: theme.borderRadius.xl,
|
||||||
|
borderBottomRightRadius: theme.borderRadius.xl,
|
||||||
|
},
|
||||||
|
headerContent: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
fontSize: theme.typography.fontSize["3xl"],
|
||||||
|
fontWeight: theme.typography.fontWeight.bold,
|
||||||
|
color: theme.colors.white,
|
||||||
|
},
|
||||||
|
headerSubtitle: {
|
||||||
|
fontSize: theme.typography.fontSize.base,
|
||||||
|
color: "rgba(255, 255, 255, 0.9)",
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
closeButton: {
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 22,
|
||||||
|
backgroundColor: "rgba(255, 255, 255, 0.2)",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
actionsBar: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: theme.colors.gray200,
|
||||||
|
},
|
||||||
|
actionText: {
|
||||||
|
fontSize: theme.typography.fontSize.sm,
|
||||||
|
fontWeight: theme.typography.fontWeight.semibold,
|
||||||
|
color: theme.colors.primary,
|
||||||
|
},
|
||||||
|
actionTextDisabled: {
|
||||||
|
color: theme.colors.gray400,
|
||||||
|
},
|
||||||
|
centered: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
emptyState: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingHorizontal: 40,
|
||||||
|
},
|
||||||
|
emptyCard: {
|
||||||
|
borderRadius: theme.borderRadius["2xl"],
|
||||||
|
padding: 40,
|
||||||
|
alignItems: "center",
|
||||||
|
width: "100%",
|
||||||
|
},
|
||||||
|
emptyTitle: {
|
||||||
|
fontSize: theme.typography.fontSize.xl,
|
||||||
|
fontWeight: theme.typography.fontWeight.bold,
|
||||||
|
color: theme.colors.gray700,
|
||||||
|
marginTop: 16,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
fontSize: theme.typography.fontSize.base,
|
||||||
|
color: theme.colors.gray500,
|
||||||
|
textAlign: "center",
|
||||||
|
lineHeight: 24,
|
||||||
|
},
|
||||||
|
listContent: {
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingVertical: 12,
|
||||||
|
},
|
||||||
|
notificationCard: {
|
||||||
|
backgroundColor: theme.colors.white,
|
||||||
|
borderRadius: theme.borderRadius.xl,
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 12,
|
||||||
|
position: "relative",
|
||||||
|
...theme.shadows.subtle,
|
||||||
|
},
|
||||||
|
unread: {
|
||||||
|
backgroundColor: "rgba(59, 130, 246, 0.05)",
|
||||||
|
borderLeftWidth: 3,
|
||||||
|
borderLeftColor: theme.colors.primary,
|
||||||
|
},
|
||||||
|
notificationContent: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
},
|
||||||
|
iconCircle: {
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
borderRadius: 24,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
textContent: {
|
||||||
|
flex: 1,
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
notificationTitle: {
|
||||||
|
fontSize: theme.typography.fontSize.base,
|
||||||
|
fontWeight: theme.typography.fontWeight.semibold,
|
||||||
|
color: theme.colors.gray700,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
notificationTitleUnread: {
|
||||||
|
fontWeight: theme.typography.fontWeight.bold,
|
||||||
|
color: theme.colors.gray900,
|
||||||
|
},
|
||||||
|
notificationMessage: {
|
||||||
|
fontSize: theme.typography.fontSize.sm,
|
||||||
|
color: theme.colors.gray600,
|
||||||
|
lineHeight: 20,
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
notificationTime: {
|
||||||
|
fontSize: theme.typography.fontSize.xs,
|
||||||
|
color: theme.colors.gray400,
|
||||||
|
},
|
||||||
|
deleteButton: {
|
||||||
|
padding: 4,
|
||||||
|
},
|
||||||
|
unreadDot: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 16,
|
||||||
|
right: 16,
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
backgroundColor: theme.colors.primary,
|
||||||
|
},
|
||||||
|
});
|
||||||
124
apps/mobile/src/components/NutritionWidget.tsx
Normal file
124
apps/mobile/src/components/NutritionWidget.tsx
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { View, Text, StyleSheet } from "react-native";
|
||||||
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { theme } from "../styles/theme";
|
||||||
|
|
||||||
|
interface NutritionWidgetProps {
|
||||||
|
current: number; // in kcal
|
||||||
|
goal: number; // in kcal
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NutritionWidget({ current, goal }: NutritionWidgetProps) {
|
||||||
|
const percentage = Math.min(Math.max(current / goal, 0), 1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={["#fef3c7", "#fde68a"]} // Light yellow background
|
||||||
|
style={[styles.card, theme.shadows.subtle]}
|
||||||
|
>
|
||||||
|
<View style={styles.content}>
|
||||||
|
<View style={styles.iconContainer}>
|
||||||
|
<LinearGradient colors={theme.gradients.sunset} style={styles.icon}>
|
||||||
|
<Ionicons name="nutrition" size={24} color="#fff" />
|
||||||
|
</LinearGradient>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.info}>
|
||||||
|
<Text style={styles.title}>Nutrition</Text>
|
||||||
|
<Text style={styles.subtitle}>
|
||||||
|
{current} / {goal} kcal
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.percentageContainer}>
|
||||||
|
<Text style={styles.percentage}>
|
||||||
|
{Math.round(percentage * 100)}%
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.progressContainer}>
|
||||||
|
<View style={styles.progressBarBg}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={theme.gradients.sunset}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 0 }}
|
||||||
|
style={[
|
||||||
|
styles.progressBarFill,
|
||||||
|
{ width: `${percentage * 100}%` },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</LinearGradient>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
marginHorizontal: 20,
|
||||||
|
marginBottom: 24,
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
borderRadius: 24,
|
||||||
|
padding: 20,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "#fcd34d",
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
iconContainer: {
|
||||||
|
marginRight: 16,
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
borderRadius: 16,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "700",
|
||||||
|
color: theme.colors.gray900,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: theme.colors.gray600,
|
||||||
|
fontWeight: "500",
|
||||||
|
},
|
||||||
|
percentageContainer: {
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 12,
|
||||||
|
},
|
||||||
|
percentage: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "700",
|
||||||
|
color: theme.colors.primary,
|
||||||
|
},
|
||||||
|
progressContainer: {
|
||||||
|
height: 8,
|
||||||
|
},
|
||||||
|
progressBarBg: {
|
||||||
|
height: 8,
|
||||||
|
backgroundColor: "rgba(255, 255, 255, 0.5)",
|
||||||
|
borderRadius: 4,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
progressBarFill: {
|
||||||
|
height: "100%",
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -1,10 +1,16 @@
|
|||||||
import React from 'react';
|
import React from "react";
|
||||||
import { View, Text, StyleSheet, TouchableOpacity, 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,
|
||||||
|
TouchableOpacity,
|
||||||
|
Dimensions,
|
||||||
|
} from "react-native";
|
||||||
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { theme } from "../styles/theme";
|
||||||
|
|
||||||
const { width } = Dimensions.get('window');
|
const { width } = Dimensions.get("window");
|
||||||
const ITEM_WIDTH = (width - 40 - 16) / 2; // (Screen width - padding - gap) / 2
|
const ITEM_WIDTH = (width - 40 - 16) / 2; // (Screen width - padding - gap) / 2
|
||||||
|
|
||||||
interface QuickActionProps {
|
interface QuickActionProps {
|
||||||
@ -16,9 +22,13 @@ interface QuickActionProps {
|
|||||||
|
|
||||||
function QuickActionItem({ icon, label, gradient, onPress }: QuickActionProps) {
|
function QuickActionItem({ icon, label, gradient, onPress }: QuickActionProps) {
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity onPress={onPress} activeOpacity={0.8} style={styles.itemContainer}>
|
<TouchableOpacity
|
||||||
|
onPress={onPress}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
style={styles.itemContainer}
|
||||||
|
>
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
colors={['rgba(255, 255, 255, 0.8)', 'rgba(255, 255, 255, 0.6)']}
|
colors={["rgba(255, 255, 255, 0.8)", "rgba(255, 255, 255, 0.6)"]}
|
||||||
style={[styles.item, theme.shadows.subtle]}
|
style={[styles.item, theme.shadows.subtle]}
|
||||||
>
|
>
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
@ -30,7 +40,12 @@ function QuickActionItem({ icon, label, gradient, onPress }: QuickActionProps) {
|
|||||||
<Ionicons name={icon} size={24} color="#fff" />
|
<Ionicons name={icon} size={24} color="#fff" />
|
||||||
</LinearGradient>
|
</LinearGradient>
|
||||||
<Text style={styles.label}>{label}</Text>
|
<Text style={styles.label}>{label}</Text>
|
||||||
<Ionicons name="chevron-forward" size={16} color={theme.colors.gray400} style={styles.arrow} />
|
<Ionicons
|
||||||
|
name="chevron-forward"
|
||||||
|
size={16}
|
||||||
|
color={theme.colors.gray400}
|
||||||
|
style={styles.arrow}
|
||||||
|
/>
|
||||||
</LinearGradient>
|
</LinearGradient>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
@ -40,9 +55,15 @@ interface QuickActionGridProps {
|
|||||||
onTrackMealPress?: () => void;
|
onTrackMealPress?: () => void;
|
||||||
onAddWaterPress?: () => void;
|
onAddWaterPress?: () => void;
|
||||||
onScanFoodPress?: () => void;
|
onScanFoodPress?: () => void;
|
||||||
|
onLogWorkoutPress?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function QuickActionGrid({ onTrackMealPress, onAddWaterPress, onScanFoodPress }: QuickActionGridProps) {
|
export function QuickActionGrid({
|
||||||
|
onTrackMealPress,
|
||||||
|
onAddWaterPress,
|
||||||
|
onScanFoodPress,
|
||||||
|
onLogWorkoutPress,
|
||||||
|
}: QuickActionGridProps) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<Text style={styles.sectionTitle}>Quick Actions</Text>
|
<Text style={styles.sectionTitle}>Quick Actions</Text>
|
||||||
@ -51,6 +72,7 @@ export function QuickActionGrid({ onTrackMealPress, onAddWaterPress, onScanFoodP
|
|||||||
icon="barbell"
|
icon="barbell"
|
||||||
label="Log Workout"
|
label="Log Workout"
|
||||||
gradient={theme.gradients.primary}
|
gradient={theme.gradients.primary}
|
||||||
|
onPress={onLogWorkoutPress}
|
||||||
/>
|
/>
|
||||||
<QuickActionItem
|
<QuickActionItem
|
||||||
icon="restaurant"
|
icon="restaurant"
|
||||||
@ -82,38 +104,38 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
sectionTitle: {
|
sectionTitle: {
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: '700',
|
fontWeight: "700",
|
||||||
color: theme.colors.gray900,
|
color: theme.colors.gray900,
|
||||||
marginBottom: 16,
|
marginBottom: 16,
|
||||||
},
|
},
|
||||||
grid: {
|
grid: {
|
||||||
flexDirection: 'row',
|
flexDirection: "row",
|
||||||
flexWrap: 'wrap',
|
flexWrap: "wrap",
|
||||||
gap: 16,
|
gap: 16,
|
||||||
},
|
},
|
||||||
itemContainer: {
|
itemContainer: {
|
||||||
width: ITEM_WIDTH,
|
width: ITEM_WIDTH,
|
||||||
},
|
},
|
||||||
item: {
|
item: {
|
||||||
flexDirection: 'row',
|
flexDirection: "row",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
padding: 16,
|
padding: 16,
|
||||||
borderRadius: 20,
|
borderRadius: 20,
|
||||||
backgroundColor: '#fff',
|
backgroundColor: "#fff",
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: 'rgba(255, 255, 255, 0.6)',
|
borderColor: "rgba(255, 255, 255, 0.6)",
|
||||||
},
|
},
|
||||||
iconContainer: {
|
iconContainer: {
|
||||||
width: 40,
|
width: 40,
|
||||||
height: 40,
|
height: 40,
|
||||||
borderRadius: 14,
|
borderRadius: 14,
|
||||||
justifyContent: 'center',
|
justifyContent: "center",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
marginRight: 12,
|
marginRight: 12,
|
||||||
},
|
},
|
||||||
label: {
|
label: {
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: '600',
|
fontWeight: "600",
|
||||||
color: theme.colors.gray800,
|
color: theme.colors.gray800,
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { Ionicons } from "@expo/vector-icons";
|
|||||||
import { theme } from "../styles/theme";
|
import { theme } from "../styles/theme";
|
||||||
import { useStatistics } from "../contexts/StatisticsContext";
|
import { useStatistics } from "../contexts/StatisticsContext";
|
||||||
import type { WeeklyTrendData } from "../api/types";
|
import type { WeeklyTrendData } from "../api/types";
|
||||||
|
import log from "../utils/logger";
|
||||||
|
|
||||||
export function WeeklyProgressWidget() {
|
export function WeeklyProgressWidget() {
|
||||||
const { statistics, loading, refetchStatistics } = useStatistics();
|
const { statistics, loading, refetchStatistics } = useStatistics();
|
||||||
@ -16,9 +17,24 @@ export function WeeklyProgressWidget() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (statistics?.weeklyTrend) {
|
if (statistics?.weeklyTrend) {
|
||||||
|
log.debug("WeeklyProgressWidget - Processing weekly trend", {
|
||||||
|
weeklyTrendLength: statistics.weeklyTrend.length,
|
||||||
|
weeklyTrend: statistics.weeklyTrend,
|
||||||
|
statisticsKeys: Object.keys(statistics),
|
||||||
|
});
|
||||||
|
|
||||||
// Get last 4 weeks for compact display
|
// Get last 4 weeks for compact display
|
||||||
const last4Weeks = statistics.weeklyTrend.slice(-4);
|
const last4Weeks = statistics.weeklyTrend.slice(-4);
|
||||||
setWeeklyData(last4Weeks);
|
setWeeklyData(last4Weeks);
|
||||||
|
|
||||||
|
log.debug("WeeklyProgressWidget - Set weekly data", {
|
||||||
|
last4Weeks,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
log.debug("WeeklyProgressWidget - No weekly trend data", {
|
||||||
|
hasStatistics: !!statistics,
|
||||||
|
statistics,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, [statistics]);
|
}, [statistics]);
|
||||||
|
|
||||||
|
|||||||
200
apps/mobile/src/contexts/NotificationsContext.tsx
Normal file
200
apps/mobile/src/contexts/NotificationsContext.tsx
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
import React, {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
useCallback,
|
||||||
|
useRef,
|
||||||
|
} from "react";
|
||||||
|
import { useAuth } from "@clerk/clerk-expo";
|
||||||
|
import {
|
||||||
|
fetchNotifications,
|
||||||
|
fetchUnreadCount,
|
||||||
|
markAsRead,
|
||||||
|
markAllAsRead,
|
||||||
|
deleteNotification,
|
||||||
|
type Notification,
|
||||||
|
} from "../api/notifications";
|
||||||
|
import log from "../utils/logger";
|
||||||
|
|
||||||
|
interface NotificationsContextType {
|
||||||
|
notifications: Notification[];
|
||||||
|
unreadCount: number;
|
||||||
|
loading: boolean;
|
||||||
|
refetchNotifications: () => Promise<void>;
|
||||||
|
markNotificationAsRead: (id: string) => Promise<void>;
|
||||||
|
markAllAsReadAction: () => Promise<void>;
|
||||||
|
deleteNotificationAction: (id: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NotificationsContext = createContext<
|
||||||
|
NotificationsContextType | undefined
|
||||||
|
>(undefined);
|
||||||
|
|
||||||
|
export function NotificationsProvider({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const { getToken, isSignedIn } = useAuth();
|
||||||
|
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||||
|
const [unreadCount, setUnreadCount] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const fetchInProgressRef = useRef(false);
|
||||||
|
const lastFetchTimeRef = useRef(0);
|
||||||
|
|
||||||
|
const refetchNotifications = useCallback(async () => {
|
||||||
|
if (!isSignedIn) return;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const timeSinceLastFetch = now - lastFetchTimeRef.current;
|
||||||
|
|
||||||
|
// Prevent duplicate concurrent fetches
|
||||||
|
if (fetchInProgressRef.current) {
|
||||||
|
log.debug("Skipping duplicate notification fetch (already in progress)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounce: prevent fetches within 1 second of the last fetch
|
||||||
|
if (timeSinceLastFetch < 1000) {
|
||||||
|
log.debug("Skipping duplicate notification fetch (debounced)", {
|
||||||
|
timeSinceLastFetch: `${timeSinceLastFetch}ms`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
fetchInProgressRef.current = true;
|
||||||
|
lastFetchTimeRef.current = now;
|
||||||
|
setLoading(true);
|
||||||
|
const token = await getToken();
|
||||||
|
|
||||||
|
// Fetch notifications and unread count in parallel
|
||||||
|
const [notificationsData, count] = await Promise.all([
|
||||||
|
fetchNotifications(token),
|
||||||
|
fetchUnreadCount(token),
|
||||||
|
]);
|
||||||
|
|
||||||
|
setNotifications(notificationsData);
|
||||||
|
setUnreadCount(count);
|
||||||
|
|
||||||
|
log.debug("Notifications fetched", {
|
||||||
|
total: notificationsData.length,
|
||||||
|
unread: count,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Failed to fetch notifications", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
fetchInProgressRef.current = false;
|
||||||
|
}
|
||||||
|
}, [getToken, isSignedIn]);
|
||||||
|
|
||||||
|
const markNotificationAsRead = useCallback(
|
||||||
|
async (id: string) => {
|
||||||
|
try {
|
||||||
|
const token = await getToken();
|
||||||
|
await markAsRead(id, token);
|
||||||
|
|
||||||
|
// Update local state
|
||||||
|
setNotifications((prev) =>
|
||||||
|
prev.map((n) => (n.id === id ? { ...n, read: true } : n)),
|
||||||
|
);
|
||||||
|
|
||||||
|
setUnreadCount((prev) => Math.max(0, prev - 1));
|
||||||
|
|
||||||
|
log.debug("Notification marked as read", { id });
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Failed to mark notification as read", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[getToken],
|
||||||
|
);
|
||||||
|
|
||||||
|
const markAllAsReadAction = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const token = await getToken();
|
||||||
|
await markAllAsRead(token);
|
||||||
|
|
||||||
|
// Update local state
|
||||||
|
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
|
||||||
|
setUnreadCount(0);
|
||||||
|
|
||||||
|
log.debug("All notifications marked as read");
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Failed to mark all notifications as read", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}, [getToken]);
|
||||||
|
|
||||||
|
const deleteNotificationAction = useCallback(
|
||||||
|
async (id: string) => {
|
||||||
|
try {
|
||||||
|
const token = await getToken();
|
||||||
|
await deleteNotification(id, token);
|
||||||
|
|
||||||
|
// Update local state
|
||||||
|
const wasUnread =
|
||||||
|
notifications.find((n) => n.id === id)?.read === false;
|
||||||
|
setNotifications((prev) => prev.filter((n) => n.id !== id));
|
||||||
|
|
||||||
|
if (wasUnread) {
|
||||||
|
setUnreadCount((prev) => Math.max(0, prev - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug("Notification deleted", { id });
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Failed to delete notification", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[getToken, notifications],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Initial fetch on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (isSignedIn) {
|
||||||
|
refetchNotifications();
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [isSignedIn]); // Only run when sign-in state changes
|
||||||
|
|
||||||
|
// Periodic refresh every 30 seconds
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSignedIn) return;
|
||||||
|
|
||||||
|
const intervalId = setInterval(() => {
|
||||||
|
refetchNotifications();
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
return () => clearInterval(intervalId);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [isSignedIn]); // Only run when sign-in state changes
|
||||||
|
|
||||||
|
const value: NotificationsContextType = {
|
||||||
|
notifications,
|
||||||
|
unreadCount,
|
||||||
|
loading,
|
||||||
|
refetchNotifications,
|
||||||
|
markNotificationAsRead,
|
||||||
|
markAllAsReadAction,
|
||||||
|
deleteNotificationAction,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NotificationsContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</NotificationsContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNotifications() {
|
||||||
|
const context = useContext(NotificationsContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error(
|
||||||
|
"useNotifications must be used within a NotificationsProvider",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@ -9,6 +9,7 @@ interface StatisticsContextValue {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: Error | null;
|
error: Error | null;
|
||||||
refetchStatistics: () => Promise<void>;
|
refetchStatistics: () => Promise<void>;
|
||||||
|
forceRefresh: () => Promise<void>;
|
||||||
clearCache: () => void;
|
clearCache: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -56,7 +57,13 @@ export function StatisticsProvider({
|
|||||||
|
|
||||||
setStatistics(stats);
|
setStatistics(stats);
|
||||||
setLastFetchTime(now);
|
setLastFetchTime(now);
|
||||||
log.debug("Statistics fetched and cached", { stats });
|
log.debug("Statistics fetched and cached", {
|
||||||
|
userId: user.id,
|
||||||
|
hasWeeklyTrend: !!stats.weeklyTrend,
|
||||||
|
weeklyTrendLength: stats.weeklyTrend?.length || 0,
|
||||||
|
weeklyTrendSample: stats.weeklyTrend?.[0],
|
||||||
|
stats,
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const error = err instanceof Error ? err : new Error(String(err));
|
const error = err instanceof Error ? err : new Error(String(err));
|
||||||
log.error("Failed to fetch statistics", error);
|
log.error("Failed to fetch statistics", error);
|
||||||
@ -73,6 +80,35 @@ export function StatisticsProvider({
|
|||||||
log.debug("Statistics cache cleared");
|
log.debug("Statistics cache cleared");
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const forceRefresh = useCallback(async () => {
|
||||||
|
if (!user?.id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
log.debug("Force fetching statistics", { userId: user.id });
|
||||||
|
|
||||||
|
const token = await getToken();
|
||||||
|
const stats = await getUserStatistics(user.id, token);
|
||||||
|
|
||||||
|
setStatistics(stats);
|
||||||
|
setLastFetchTime(Date.now());
|
||||||
|
log.debug("Statistics force fetched and cached", {
|
||||||
|
userId: user.id,
|
||||||
|
hasWeeklyTrend: !!stats.weeklyTrend,
|
||||||
|
weeklyTrendLength: stats.weeklyTrend?.length || 0,
|
||||||
|
weeklyTrendSample: stats.weeklyTrend?.[0],
|
||||||
|
stats,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const error = err instanceof Error ? err : new Error(String(err));
|
||||||
|
log.error("Failed to force fetch statistics", error);
|
||||||
|
setError(error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [user?.id, getToken]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StatisticsContext.Provider
|
<StatisticsContext.Provider
|
||||||
value={{
|
value={{
|
||||||
@ -80,6 +116,7 @@ export function StatisticsProvider({
|
|||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
refetchStatistics,
|
refetchStatistics,
|
||||||
|
forceRefresh,
|
||||||
clearCache,
|
clearCache,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
126
apps/mobile/src/hooks/useNotificationPermissions.ts
Normal file
126
apps/mobile/src/hooks/useNotificationPermissions.ts
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
import { useEffect, useRef, useCallback } from "react";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
import * as Notifications from "expo-notifications";
|
||||||
|
import * as Device from "expo-device";
|
||||||
|
import { useAuth } from "@clerk/clerk-expo";
|
||||||
|
import { savePushToken } from "../api/notifications";
|
||||||
|
import log from "../utils/logger";
|
||||||
|
|
||||||
|
// Configure notification behavior
|
||||||
|
Notifications.setNotificationHandler({
|
||||||
|
handleNotification: async () => ({
|
||||||
|
shouldShowAlert: true,
|
||||||
|
shouldPlaySound: true,
|
||||||
|
shouldSetBadge: true,
|
||||||
|
shouldShowBanner: true,
|
||||||
|
shouldShowList: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to register for push notifications and save the token
|
||||||
|
*/
|
||||||
|
export function useNotificationPermissions() {
|
||||||
|
const { getToken, isSignedIn } = useAuth();
|
||||||
|
const notificationListener = useRef<Notifications.Subscription | undefined>(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
const responseListener = useRef<Notifications.Subscription | undefined>(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
const registerForPushNotifications = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
// Only works on physical devices (not simulators/emulators)
|
||||||
|
if (!Device.isDevice) {
|
||||||
|
log.warn("Push notifications only work on physical devices");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check existing permissions
|
||||||
|
// @ts-ignore - expo-notifications type mismatch
|
||||||
|
const { status: existingStatus } =
|
||||||
|
await Notifications.getPermissionsAsync();
|
||||||
|
|
||||||
|
let finalStatus = existingStatus;
|
||||||
|
|
||||||
|
// Request permissions if not already granted
|
||||||
|
if (existingStatus !== "granted") {
|
||||||
|
// @ts-ignore - expo-notifications type mismatch
|
||||||
|
const { status } = await Notifications.requestPermissionsAsync();
|
||||||
|
finalStatus = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalStatus !== "granted") {
|
||||||
|
log.warn("Failed to get push notification permissions");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the Expo push token
|
||||||
|
const expoPushToken = (await Notifications.getExpoPushTokenAsync()).data;
|
||||||
|
|
||||||
|
log.info("Expo push token obtained", {
|
||||||
|
tokenPrefix: expoPushToken.substring(0, 20) + "...",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Determine device type
|
||||||
|
const deviceType = Platform.OS === "ios" ? "ios" : "android";
|
||||||
|
|
||||||
|
// Save token to backend
|
||||||
|
const authToken = await getToken();
|
||||||
|
await savePushToken(expoPushToken, deviceType, authToken);
|
||||||
|
|
||||||
|
log.info("Push token saved to backend", { deviceType });
|
||||||
|
|
||||||
|
// Configure Android notification channel
|
||||||
|
if (Platform.OS === "android") {
|
||||||
|
await Notifications.setNotificationChannelAsync("default", {
|
||||||
|
name: "default",
|
||||||
|
importance: Notifications.AndroidImportance.MAX,
|
||||||
|
vibrationPattern: [0, 250, 250, 250],
|
||||||
|
lightColor: "#FF231F7C",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// In Expo Go, remote push notifications are not available (removed in SDK 53)
|
||||||
|
// This is expected and doesn't affect in-app notifications
|
||||||
|
if (!Device.isDevice || __DEV__) {
|
||||||
|
log.info(
|
||||||
|
"Push notification registration skipped (Expo Go or development mode)",
|
||||||
|
{ error: error instanceof Error ? error.message : String(error) },
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
log.error("Failed to register for push notifications", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [getToken]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSignedIn) return;
|
||||||
|
|
||||||
|
// Register for push notifications
|
||||||
|
registerForPushNotifications();
|
||||||
|
|
||||||
|
// Listener for notifications received while app is foregrounded
|
||||||
|
notificationListener.current =
|
||||||
|
Notifications.addNotificationReceivedListener((notification) => {
|
||||||
|
log.debug("Notification received in foreground", {
|
||||||
|
title: notification.request.content.title,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listener for when user taps on a notification
|
||||||
|
responseListener.current =
|
||||||
|
Notifications.addNotificationResponseReceivedListener((response) => {
|
||||||
|
log.debug("Notification tapped", {
|
||||||
|
data: response.notification.request.content.data,
|
||||||
|
});
|
||||||
|
// TODO: Handle navigation based on notification type
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
notificationListener.current?.remove();
|
||||||
|
responseListener.current?.remove();
|
||||||
|
};
|
||||||
|
}, [isSignedIn, registerForPushNotifications]);
|
||||||
|
}
|
||||||
@ -1,11 +1,10 @@
|
|||||||
import { defineConfig } from "drizzle-kit";
|
import type { Config } from "drizzle-kit";
|
||||||
|
|
||||||
export default defineConfig({
|
export default {
|
||||||
schema: "./src/schema.ts",
|
schema: "./src/schema.ts",
|
||||||
out: "./drizzle",
|
out: "./drizzle",
|
||||||
dialect: "sqlite",
|
dialect: "sqlite",
|
||||||
dbCredentials: {
|
dbCredentials: {
|
||||||
// url: "./fitai.db",
|
|
||||||
url: "../../apps/admin/data/fitai.db",
|
url: "../../apps/admin/data/fitai.db",
|
||||||
},
|
},
|
||||||
});
|
} satisfies Config;
|
||||||
|
|||||||
@ -22,6 +22,8 @@ export const users = sqliteTable(
|
|||||||
.default("client"),
|
.default("client"),
|
||||||
phone: text("phone"),
|
phone: text("phone"),
|
||||||
gymId: text("gym_id"), // FK reference added after gyms table
|
gymId: text("gym_id"), // FK reference added after gyms table
|
||||||
|
expoPushToken: text("expo_push_token"), // For push notifications
|
||||||
|
deviceType: text("device_type", { enum: ["ios", "android"] }), // Device platform
|
||||||
createdAt: integer("created_at", { mode: "timestamp" })
|
createdAt: integer("created_at", { mode: "timestamp" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.$defaultFn(() => new Date()),
|
.$defaultFn(() => new Date()),
|
||||||
@ -33,6 +35,9 @@ export const users = sqliteTable(
|
|||||||
emailIdx: index("users_email_idx").on(table.email),
|
emailIdx: index("users_email_idx").on(table.email),
|
||||||
gymIdIdx: index("users_gym_id_idx").on(table.gymId),
|
gymIdIdx: index("users_gym_id_idx").on(table.gymId),
|
||||||
roleIdx: index("users_role_idx").on(table.role),
|
roleIdx: index("users_role_idx").on(table.role),
|
||||||
|
expoPushTokenIdx: index("users_expo_push_token_idx").on(
|
||||||
|
table.expoPushToken,
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -25,6 +25,8 @@ export interface User {
|
|||||||
role: UserRole;
|
role: UserRole;
|
||||||
gymId?: string;
|
gymId?: string;
|
||||||
imageUrl?: string;
|
imageUrl?: string;
|
||||||
|
expoPushToken?: string;
|
||||||
|
deviceType?: "ios" | "android";
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user