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,49 +1,87 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getDatabase } from "@/lib/database";
|
||||
import log from "@/lib/logger";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const { recommendationId, status, approvedBy } = await req.json();
|
||||
try {
|
||||
const body = await req.json();
|
||||
log.debug("Approve recommendation request body", { body });
|
||||
|
||||
if (!recommendationId || !status) {
|
||||
return NextResponse.json(
|
||||
{ error: "Recommendation ID and status are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const { recommendationId, status, approvedBy } = body;
|
||||
|
||||
const db = await getDatabase();
|
||||
|
||||
// Update recommendation status
|
||||
const updates: any = {
|
||||
status,
|
||||
approvedAt: status === "approved" ? new Date() : undefined,
|
||||
approvedBy: status === "approved" ? approvedBy : undefined,
|
||||
};
|
||||
|
||||
// Remove undefined keys
|
||||
Object.keys(updates).forEach(key => updates[key] === undefined && delete updates[key]);
|
||||
|
||||
const updatedRecommendation = await db.updateRecommendation(recommendationId, updates);
|
||||
|
||||
if (!updatedRecommendation) {
|
||||
return NextResponse.json(
|
||||
{ error: "Recommendation not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// If approved, create a notification for the user
|
||||
// Note: IDatabase doesn't have createNotification yet, so we'll skip it for now
|
||||
// or we need to add it to IDatabase/SQLiteDatabase
|
||||
// For now, let's assume the notification is handled elsewhere or add it later
|
||||
|
||||
return NextResponse.json(updatedRecommendation);
|
||||
} catch (error) {
|
||||
console.error("Error approving recommendation:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
if (!recommendationId || !status) {
|
||||
log.error("Missing required fields", {
|
||||
recommendationId,
|
||||
status,
|
||||
receivedBody: body,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: "Recommendation ID and status are required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const db = await getDatabase();
|
||||
|
||||
// Update recommendation status
|
||||
const updates: any = {
|
||||
status,
|
||||
approvedAt: status === "approved" ? new Date() : undefined,
|
||||
approvedBy: status === "approved" ? approvedBy : undefined,
|
||||
};
|
||||
|
||||
// Remove undefined keys
|
||||
Object.keys(updates).forEach(
|
||||
(key) => updates[key] === undefined && delete updates[key],
|
||||
);
|
||||
|
||||
const updatedRecommendation = await db.updateRecommendation(
|
||||
recommendationId,
|
||||
updates,
|
||||
);
|
||||
|
||||
if (!updatedRecommendation) {
|
||||
return NextResponse.json(
|
||||
{ error: "Recommendation not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
// If approved, create a notification for the user
|
||||
if (status === "approved") {
|
||||
try {
|
||||
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,
|
||||
});
|
||||
|
||||
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) {
|
||||
log.error("Error approving recommendation", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ 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();
|
||||
|
||||
// Fetch fitness profile
|
||||
const profile = await db.getFitnessProfileByUserId(userId);
|
||||
|
||||
if (!profile) {
|
||||
log.error("Fitness profile not found", undefined, { userId });
|
||||
return NextResponse.json(
|
||||
{ error: "Fitness profile not found for this user" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
log.debug("Fitness profile found", { profileId: profile.id });
|
||||
|
||||
// Build AI context with goals and recommendations
|
||||
let prompt: string;
|
||||
try {
|
||||
@ -270,21 +279,45 @@ export async function POST(req: Request) {
|
||||
}
|
||||
|
||||
// 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({
|
||||
id: crypto.randomUUID(),
|
||||
userId,
|
||||
fitnessProfileId: profile.id,
|
||||
recommendationText: parsedResponse.recommendationText,
|
||||
activityPlan: parsedResponse.activityPlan,
|
||||
dietPlan: parsedResponse.dietPlan,
|
||||
recommendationText: parsedResponse.recommendationText || "",
|
||||
activityPlan: parsedResponse.activityPlan || "",
|
||||
dietPlan: parsedResponse.dietPlan || "",
|
||||
status: "pending",
|
||||
generatedAt: 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) {
|
||||
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(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
|
||||
@ -28,7 +28,7 @@ interface AttendanceStats {
|
||||
thisMonth: number;
|
||||
recentCheckIns: Array<{
|
||||
id: string;
|
||||
checkInTime: string;
|
||||
checkInTime: string; // ISO string
|
||||
checkOutTime: string | null;
|
||||
type: string;
|
||||
duration?: number; // in minutes
|
||||
@ -134,8 +134,8 @@ export async function GET(request: NextRequest) {
|
||||
)
|
||||
.all(userId) as Array<{
|
||||
id: string;
|
||||
checkInTime: string;
|
||||
checkOutTime: string | null;
|
||||
checkInTime: string | number; // Can be ISO string or Unix timestamp
|
||||
checkOutTime: string | number | null;
|
||||
type: string;
|
||||
}>;
|
||||
|
||||
@ -144,13 +144,33 @@ export async function GET(request: NextRequest) {
|
||||
// Get recent check-ins (last 10)
|
||||
const recentCheckIns = attendanceRecords.slice(0, 10).map((record) => {
|
||||
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) {
|
||||
const checkIn = new Date(record.checkInTime).getTime();
|
||||
const checkOut = new Date(record.checkOutTime).getTime();
|
||||
duration = Math.round((checkOut - checkIn) / (1000 * 60)); // minutes
|
||||
const checkOutMs =
|
||||
typeof record.checkOutTime === "number"
|
||||
? record.checkOutTime * 1000
|
||||
: new Date(record.checkOutTime).getTime();
|
||||
duration = Math.round((checkOutMs - checkInMs) / (1000 * 60)); // minutes
|
||||
}
|
||||
|
||||
// Return with ISO string timestamps for consistency
|
||||
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,
|
||||
};
|
||||
});
|
||||
@ -161,15 +181,25 @@ export async function GET(request: NextRequest) {
|
||||
startOfWeek.setDate(now.getDate() - now.getDay()); // Sunday
|
||||
startOfWeek.setHours(0, 0, 0, 0);
|
||||
|
||||
const thisWeekCheckIns = attendanceRecords.filter(
|
||||
(r) => new Date(r.checkInTime) >= startOfWeek,
|
||||
).length;
|
||||
const thisWeekCheckIns = attendanceRecords.filter((r) => {
|
||||
// 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 >= startOfWeek;
|
||||
}).length;
|
||||
|
||||
// Calculate this month's check-ins
|
||||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const thisMonthCheckIns = attendanceRecords.filter(
|
||||
(r) => new Date(r.checkInTime) >= startOfMonth,
|
||||
).length;
|
||||
const thisMonthCheckIns = attendanceRecords.filter((r) => {
|
||||
// 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 >= startOfMonth;
|
||||
}).length;
|
||||
|
||||
// Calculate current streak (consecutive days with check-ins)
|
||||
let currentStreak = 0;
|
||||
@ -177,14 +207,24 @@ export async function GET(request: NextRequest) {
|
||||
let tempStreak = 0;
|
||||
let lastDate: Date | null = null;
|
||||
|
||||
const sortedRecords = [...attendanceRecords].sort(
|
||||
(a, b) =>
|
||||
new Date(b.checkInTime).getTime() - new Date(a.checkInTime).getTime(),
|
||||
);
|
||||
const sortedRecords = [...attendanceRecords].sort((a, b) => {
|
||||
const dateA =
|
||||
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>();
|
||||
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];
|
||||
uniqueDays.add(dateStr);
|
||||
});
|
||||
@ -257,10 +297,32 @@ export async function GET(request: NextRequest) {
|
||||
weekEnd.setDate(weekStart.getDate() + 7);
|
||||
|
||||
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;
|
||||
}).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
|
||||
.prepare(
|
||||
`SELECT COUNT(*) as count
|
||||
@ -308,14 +370,42 @@ export async function GET(request: NextRequest) {
|
||||
userId,
|
||||
totalGoals: goalStats.total,
|
||||
totalCheckIns,
|
||||
weeklyTrendLength: weeklyTrend.length,
|
||||
weeklyTrendSample: weeklyTrend[0],
|
||||
});
|
||||
|
||||
// Return statistics in standardized format that matches mobile app expectations
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
statistics: {
|
||||
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: {
|
||||
@ -323,15 +413,6 @@ export async function GET(request: NextRequest) {
|
||||
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) {
|
||||
log.error("Failed to fetch statistics", error);
|
||||
return NextResponse.json(
|
||||
|
||||
@ -83,7 +83,10 @@ export function Recommendations({ userId }: RecommendationsProps) {
|
||||
const response = await fetch("/api/recommendations/approve", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ recommendationId }),
|
||||
body: JSON.stringify({
|
||||
recommendationId,
|
||||
status: "approved",
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
|
||||
@ -6,6 +6,7 @@ import {
|
||||
Attendance,
|
||||
Recommendation,
|
||||
FitnessGoal,
|
||||
Notification,
|
||||
DatabaseConfig,
|
||||
} from "./types";
|
||||
import {
|
||||
@ -16,6 +17,7 @@ import {
|
||||
attendance,
|
||||
recommendations,
|
||||
fitnessGoals,
|
||||
notifications,
|
||||
eq,
|
||||
and,
|
||||
desc,
|
||||
@ -1419,4 +1421,94 @@ export class DrizzleDatabase implements IDatabase {
|
||||
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,
|
||||
Recommendation,
|
||||
FitnessGoal,
|
||||
Notification,
|
||||
} from "@fitai/shared";
|
||||
import type { SortConfig, FilterCondition } from "../filtering";
|
||||
|
||||
@ -15,7 +16,14 @@ export interface User extends SharedUser {
|
||||
password: string;
|
||||
}
|
||||
|
||||
export type { Client, FitnessProfile, Attendance, Recommendation, FitnessGoal };
|
||||
export type {
|
||||
Client,
|
||||
FitnessProfile,
|
||||
Attendance,
|
||||
Recommendation,
|
||||
FitnessGoal,
|
||||
Notification,
|
||||
};
|
||||
|
||||
// Database Interface - allows us to swap implementations
|
||||
export interface IDatabase {
|
||||
@ -163,6 +171,16 @@ export interface IDatabase {
|
||||
): 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
|
||||
getDashboardStats(): Promise<{
|
||||
totalUsers: number;
|
||||
|
||||
@ -83,7 +83,7 @@ export const prioritySchema = z.enum(["low", "medium", "high"]);
|
||||
export const goalStatusSchema = z.enum(["active", "completed", "abandoned"]);
|
||||
|
||||
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,
|
||||
title: z.string().min(1, "Title is required").max(100),
|
||||
description: z.string().max(500).optional(),
|
||||
|
||||
@ -11,13 +11,12 @@
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"assetBundlePatterns": [
|
||||
"**/*"
|
||||
],
|
||||
"assetBundlePatterns": ["**/*"],
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"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": {
|
||||
@ -25,9 +24,7 @@
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"permissions": [
|
||||
"CAMERA"
|
||||
]
|
||||
"permissions": ["CAMERA", "POST_NOTIFICATIONS"]
|
||||
},
|
||||
"web": {
|
||||
"favicon": "./assets/favicon.png"
|
||||
@ -35,7 +32,15 @@
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
"expo-font",
|
||||
"expo-barcode-scanner"
|
||||
"expo-barcode-scanner",
|
||||
[
|
||||
"expo-notifications",
|
||||
{
|
||||
"icon": "./assets/icon.png",
|
||||
"color": "#ffffff",
|
||||
"sounds": []
|
||||
}
|
||||
]
|
||||
],
|
||||
"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-constants": "^18.0.10",
|
||||
"expo-crypto": "^15.0.8",
|
||||
"expo-device": "~8.0.10",
|
||||
"expo-font": "~14.0.9",
|
||||
"expo-haptics": "^15.0.7",
|
||||
"expo-linear-gradient": "~15.0.7",
|
||||
@ -7357,6 +7358,44 @@
|
||||
"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": {
|
||||
"version": "19.0.19",
|
||||
"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-constants": "^18.0.10",
|
||||
"expo-crypto": "^15.0.8",
|
||||
"expo-device": "~8.0.10",
|
||||
"expo-font": "~14.0.9",
|
||||
"expo-haptics": "^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 token - Auth token
|
||||
* @param approvedBy - User ID of the approver (optional)
|
||||
* @returns The approved recommendation
|
||||
*/
|
||||
export async function approveRecommendation(
|
||||
recommendationId: string,
|
||||
token: string | null,
|
||||
approvedBy?: string,
|
||||
): Promise<Recommendation> {
|
||||
const headers: any = {
|
||||
"Content-Type": "application/json",
|
||||
@ -128,7 +130,11 @@ export async function approveRecommendation(
|
||||
{
|
||||
method: "POST",
|
||||
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 { attendanceApi, Attendance } from "../../api/attendance";
|
||||
import { AttendanceCalendar } from "../../components/AttendanceCalendar";
|
||||
import { useStatistics } from "../../contexts/StatisticsContext";
|
||||
import { theme } from "../../styles/theme";
|
||||
import { Animated } from "react-native";
|
||||
import { getErrorMessage } from "../../utils/error-helpers";
|
||||
@ -20,6 +21,7 @@ import log from "../../utils/logger";
|
||||
|
||||
export default function AttendanceScreen() {
|
||||
const { getToken, userId } = useAuth();
|
||||
const { clearCache: clearStatisticsCache } = useStatistics();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeCheckIn, setActiveCheckIn] = useState<Attendance | null>(null);
|
||||
const [history, setHistory] = useState<Attendance[]>([]);
|
||||
@ -80,6 +82,10 @@ export default function AttendanceScreen() {
|
||||
if (!token) return;
|
||||
|
||||
await attendanceApi.checkIn("gym", token);
|
||||
|
||||
// Clear statistics cache to force refresh on home screen
|
||||
clearStatisticsCache();
|
||||
|
||||
fetchAttendance();
|
||||
Alert.alert("Success", "Checked in successfully!");
|
||||
} catch (error: unknown) {
|
||||
@ -94,6 +100,10 @@ export default function AttendanceScreen() {
|
||||
if (!token) return;
|
||||
|
||||
await attendanceApi.checkOut(token);
|
||||
|
||||
// Clear statistics cache to force refresh on home screen
|
||||
clearStatisticsCache();
|
||||
|
||||
fetchAttendance();
|
||||
Alert.alert("Success", "Checked out successfully!");
|
||||
} catch (error: unknown) {
|
||||
|
||||
@ -9,6 +9,7 @@ import {
|
||||
import { useUser } from "@clerk/clerk-expo";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { useFocusEffect } from "@react-navigation/native";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { theme } from "../../styles/theme";
|
||||
import { ActivityWidget } from "../../components/ActivityWidget";
|
||||
@ -16,12 +17,18 @@ import { QuickActionGrid } from "../../components/QuickActionGrid";
|
||||
import { TrackMealModal } from "../../components/TrackMealModal";
|
||||
import { AddWaterModal } from "../../components/AddWaterModal";
|
||||
import { HydrationWidget } from "../../components/HydrationWidget";
|
||||
import { NutritionWidget } from "../../components/NutritionWidget";
|
||||
import { WeeklyProgressWidget } from "../../components/WeeklyProgressWidget";
|
||||
import { ScanFoodModal } from "../../components/ScanFoodModal";
|
||||
import { useStatistics } from "../../contexts/StatisticsContext";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
|
||||
const CALORIE_GOAL = 2000; // kcal
|
||||
const WATER_GOAL = 2000; // ml
|
||||
|
||||
export default function HomeScreen() {
|
||||
const { user } = useUser();
|
||||
const { refetchStatistics, forceRefresh } = useStatistics();
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [trackMealModalVisible, setTrackMealModalVisible] = useState(false);
|
||||
const [addWaterModalVisible, setAddWaterModalVisible] = useState(false);
|
||||
@ -29,12 +36,19 @@ export default function HomeScreen() {
|
||||
const [calories, setCalories] = 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);
|
||||
setTimeout(() => {
|
||||
setRefreshing(false);
|
||||
}, 2000);
|
||||
}, []);
|
||||
// Force refetch statistics bypassing cache
|
||||
await forceRefresh();
|
||||
setRefreshing(false);
|
||||
}, [forceRefresh]);
|
||||
|
||||
const getGreeting = () => {
|
||||
const hour = new Date().getHours();
|
||||
@ -75,8 +89,48 @@ export default function HomeScreen() {
|
||||
setWaterIntake(0);
|
||||
const today = new Date().toDateString();
|
||||
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
|
||||
useEffect(() => {
|
||||
const checkAndResetIfNeeded = async () => {
|
||||
@ -146,6 +200,26 @@ export default function HomeScreen() {
|
||||
{/* Activity Widget */}
|
||||
<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
|
||||
visible={trackMealModalVisible}
|
||||
onClose={() => setTrackMealModalVisible(false)}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { useState, useCallback } from "react";
|
||||
import React, { useState, useCallback, useRef } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@ -15,6 +15,8 @@ import { useUser } from "@clerk/clerk-expo";
|
||||
import { useFocusEffect } from "expo-router";
|
||||
import { theme } from "../../styles/theme";
|
||||
import { useRecommendations } from "../../contexts/RecommendationsContext";
|
||||
import { useNotifications } from "../../contexts/NotificationsContext";
|
||||
import { NotificationsModal } from "../../components/NotificationsModal";
|
||||
import type { Recommendation } from "../../api/recommendations";
|
||||
import log from "../../utils/logger";
|
||||
|
||||
@ -26,8 +28,20 @@ export default function RecommendationsScreen() {
|
||||
refetchRecommendations,
|
||||
generateNewRecommendation,
|
||||
} = useRecommendations();
|
||||
const { unreadCount, refetchNotifications } = useNotifications();
|
||||
const [generating, setGenerating] = 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
|
||||
const recommendations = allRecommendations.filter(
|
||||
@ -37,12 +51,13 @@ export default function RecommendationsScreen() {
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
refetchRecommendations();
|
||||
}, [refetchRecommendations]),
|
||||
refetchNotifications();
|
||||
}, [refetchRecommendations, refetchNotifications]),
|
||||
);
|
||||
|
||||
const onRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
await refetchRecommendations();
|
||||
await Promise.all([refetchRecommendations(), refetchNotifications()]);
|
||||
setRefreshing(false);
|
||||
};
|
||||
|
||||
@ -61,7 +76,7 @@ export default function RecommendationsScreen() {
|
||||
setGenerating(true);
|
||||
await generateNewRecommendation({
|
||||
userId: user.id,
|
||||
modelProvider: "openai",
|
||||
modelProvider: "deepseek",
|
||||
useExternalModel: true,
|
||||
});
|
||||
Alert.alert(
|
||||
@ -94,6 +109,10 @@ export default function RecommendationsScreen() {
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<NotificationsModal
|
||||
visible={notificationsVisible}
|
||||
onClose={handleCloseNotifications}
|
||||
/>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
refreshControl={
|
||||
@ -117,9 +136,18 @@ export default function RecommendationsScreen() {
|
||||
Personalized fitness & nutrition plans
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.iconContainer}>
|
||||
<TouchableOpacity
|
||||
style={styles.iconContainer}
|
||||
onPress={handleOpenNotifications}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Ionicons name="sparkles" size={32} color="#fff" />
|
||||
</View>
|
||||
{unreadCount > 0 && (
|
||||
<View style={styles.badge}>
|
||||
<Text style={styles.badgeText}>{unreadCount}</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</LinearGradient>
|
||||
|
||||
{/* Generate Button */}
|
||||
@ -314,6 +342,22 @@ const styles = StyleSheet.create({
|
||||
justifyContent: "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: {
|
||||
paddingHorizontal: 20,
|
||||
marginBottom: 20,
|
||||
|
||||
@ -7,8 +7,26 @@ import { validateEnv } from "../utils/env";
|
||||
import { StatisticsProvider } from "../contexts/StatisticsContext";
|
||||
import { FitnessGoalsProvider } from "../contexts/FitnessGoalsContext";
|
||||
import { RecommendationsProvider } from "../contexts/RecommendationsContext";
|
||||
import { NotificationsProvider } from "../contexts/NotificationsContext";
|
||||
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
|
||||
try {
|
||||
const env = validateEnv();
|
||||
@ -153,17 +171,15 @@ export default function RootLayout() {
|
||||
return (
|
||||
<ClerkProvider tokenCache={tokenCache} publishableKey={publishableKey}>
|
||||
<ClerkLoaded>
|
||||
<StatisticsProvider>
|
||||
<FitnessGoalsProvider>
|
||||
<RecommendationsProvider>
|
||||
<Stack>
|
||||
<Stack.Screen name="(auth)" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="welcome" options={{ headerShown: false }} />
|
||||
</Stack>
|
||||
</RecommendationsProvider>
|
||||
</FitnessGoalsProvider>
|
||||
</StatisticsProvider>
|
||||
<NotificationsProvider>
|
||||
<StatisticsProvider>
|
||||
<FitnessGoalsProvider>
|
||||
<RecommendationsProvider>
|
||||
<AppContent />
|
||||
</RecommendationsProvider>
|
||||
</FitnessGoalsProvider>
|
||||
</StatisticsProvider>
|
||||
</NotificationsProvider>
|
||||
</ClerkLoaded>
|
||||
</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,123 +1,145 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, Dimensions } from 'react-native';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { theme } from '../styles/theme';
|
||||
import React from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
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
|
||||
|
||||
interface QuickActionProps {
|
||||
icon: keyof typeof Ionicons.glyphMap;
|
||||
label: string;
|
||||
gradient: readonly [string, string, ...string[]];
|
||||
onPress?: () => void;
|
||||
icon: keyof typeof Ionicons.glyphMap;
|
||||
label: string;
|
||||
gradient: readonly [string, string, ...string[]];
|
||||
onPress?: () => void;
|
||||
}
|
||||
|
||||
function QuickActionItem({ icon, label, gradient, onPress }: QuickActionProps) {
|
||||
return (
|
||||
<TouchableOpacity onPress={onPress} activeOpacity={0.8} style={styles.itemContainer}>
|
||||
<LinearGradient
|
||||
colors={['rgba(255, 255, 255, 0.8)', 'rgba(255, 255, 255, 0.6)']}
|
||||
style={[styles.item, theme.shadows.subtle]}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={gradient}
|
||||
style={styles.iconContainer}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
>
|
||||
<Ionicons name={icon} size={24} color="#fff" />
|
||||
</LinearGradient>
|
||||
<Text style={styles.label}>{label}</Text>
|
||||
<Ionicons name="chevron-forward" size={16} color={theme.colors.gray400} style={styles.arrow} />
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
activeOpacity={0.8}
|
||||
style={styles.itemContainer}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={["rgba(255, 255, 255, 0.8)", "rgba(255, 255, 255, 0.6)"]}
|
||||
style={[styles.item, theme.shadows.subtle]}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={gradient}
|
||||
style={styles.iconContainer}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
>
|
||||
<Ionicons name={icon} size={24} color="#fff" />
|
||||
</LinearGradient>
|
||||
<Text style={styles.label}>{label}</Text>
|
||||
<Ionicons
|
||||
name="chevron-forward"
|
||||
size={16}
|
||||
color={theme.colors.gray400}
|
||||
style={styles.arrow}
|
||||
/>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
interface QuickActionGridProps {
|
||||
onTrackMealPress?: () => void;
|
||||
onAddWaterPress?: () => void;
|
||||
onScanFoodPress?: () => void;
|
||||
onTrackMealPress?: () => void;
|
||||
onAddWaterPress?: () => void;
|
||||
onScanFoodPress?: () => void;
|
||||
onLogWorkoutPress?: () => void;
|
||||
}
|
||||
|
||||
export function QuickActionGrid({ onTrackMealPress, onAddWaterPress, onScanFoodPress }: QuickActionGridProps) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.sectionTitle}>Quick Actions</Text>
|
||||
<View style={styles.grid}>
|
||||
<QuickActionItem
|
||||
icon="barbell"
|
||||
label="Log Workout"
|
||||
gradient={theme.gradients.primary}
|
||||
/>
|
||||
<QuickActionItem
|
||||
icon="restaurant"
|
||||
label="Track Meal"
|
||||
gradient={theme.gradients.success}
|
||||
onPress={onTrackMealPress}
|
||||
/>
|
||||
<QuickActionItem
|
||||
icon="water"
|
||||
label="Add Water"
|
||||
gradient={theme.gradients.ocean}
|
||||
onPress={onAddWaterPress}
|
||||
/>
|
||||
<QuickActionItem
|
||||
icon="scan"
|
||||
label="Scan Food"
|
||||
gradient={theme.gradients.purple}
|
||||
onPress={onScanFoodPress}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
export function QuickActionGrid({
|
||||
onTrackMealPress,
|
||||
onAddWaterPress,
|
||||
onScanFoodPress,
|
||||
onLogWorkoutPress,
|
||||
}: QuickActionGridProps) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.sectionTitle}>Quick Actions</Text>
|
||||
<View style={styles.grid}>
|
||||
<QuickActionItem
|
||||
icon="barbell"
|
||||
label="Log Workout"
|
||||
gradient={theme.gradients.primary}
|
||||
onPress={onLogWorkoutPress}
|
||||
/>
|
||||
<QuickActionItem
|
||||
icon="restaurant"
|
||||
label="Track Meal"
|
||||
gradient={theme.gradients.success}
|
||||
onPress={onTrackMealPress}
|
||||
/>
|
||||
<QuickActionItem
|
||||
icon="water"
|
||||
label="Add Water"
|
||||
gradient={theme.gradients.ocean}
|
||||
onPress={onAddWaterPress}
|
||||
/>
|
||||
<QuickActionItem
|
||||
icon="scan"
|
||||
label="Scan Food"
|
||||
gradient={theme.gradients.purple}
|
||||
onPress={onScanFoodPress}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
paddingHorizontal: 20,
|
||||
marginBottom: 24,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: theme.colors.gray900,
|
||||
marginBottom: 16,
|
||||
},
|
||||
grid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 16,
|
||||
},
|
||||
itemContainer: {
|
||||
width: ITEM_WIDTH,
|
||||
},
|
||||
item: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
borderRadius: 20,
|
||||
backgroundColor: '#fff',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.6)',
|
||||
},
|
||||
iconContainer: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 14,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 12,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: theme.colors.gray800,
|
||||
flex: 1,
|
||||
},
|
||||
arrow: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
container: {
|
||||
paddingHorizontal: 20,
|
||||
marginBottom: 24,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: "700",
|
||||
color: theme.colors.gray900,
|
||||
marginBottom: 16,
|
||||
},
|
||||
grid: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
gap: 16,
|
||||
},
|
||||
itemContainer: {
|
||||
width: ITEM_WIDTH,
|
||||
},
|
||||
item: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
padding: 16,
|
||||
borderRadius: 20,
|
||||
backgroundColor: "#fff",
|
||||
borderWidth: 1,
|
||||
borderColor: "rgba(255, 255, 255, 0.6)",
|
||||
},
|
||||
iconContainer: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 14,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
marginRight: 12,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
color: theme.colors.gray800,
|
||||
flex: 1,
|
||||
},
|
||||
arrow: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
});
|
||||
|
||||
@ -5,6 +5,7 @@ import { Ionicons } from "@expo/vector-icons";
|
||||
import { theme } from "../styles/theme";
|
||||
import { useStatistics } from "../contexts/StatisticsContext";
|
||||
import type { WeeklyTrendData } from "../api/types";
|
||||
import log from "../utils/logger";
|
||||
|
||||
export function WeeklyProgressWidget() {
|
||||
const { statistics, loading, refetchStatistics } = useStatistics();
|
||||
@ -16,9 +17,24 @@ export function WeeklyProgressWidget() {
|
||||
|
||||
useEffect(() => {
|
||||
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
|
||||
const last4Weeks = statistics.weeklyTrend.slice(-4);
|
||||
setWeeklyData(last4Weeks);
|
||||
|
||||
log.debug("WeeklyProgressWidget - Set weekly data", {
|
||||
last4Weeks,
|
||||
});
|
||||
} else {
|
||||
log.debug("WeeklyProgressWidget - No weekly trend data", {
|
||||
hasStatistics: !!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;
|
||||
error: Error | null;
|
||||
refetchStatistics: () => Promise<void>;
|
||||
forceRefresh: () => Promise<void>;
|
||||
clearCache: () => void;
|
||||
}
|
||||
|
||||
@ -56,7 +57,13 @@ export function StatisticsProvider({
|
||||
|
||||
setStatistics(stats);
|
||||
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) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
log.error("Failed to fetch statistics", error);
|
||||
@ -73,6 +80,35 @@ export function StatisticsProvider({
|
||||
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 (
|
||||
<StatisticsContext.Provider
|
||||
value={{
|
||||
@ -80,6 +116,7 @@ export function StatisticsProvider({
|
||||
loading,
|
||||
error,
|
||||
refetchStatistics,
|
||||
forceRefresh,
|
||||
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",
|
||||
out: "./drizzle",
|
||||
dialect: "sqlite",
|
||||
dbCredentials: {
|
||||
// url: "./fitai.db",
|
||||
url: "../../apps/admin/data/fitai.db",
|
||||
},
|
||||
});
|
||||
} satisfies Config;
|
||||
|
||||
@ -22,6 +22,8 @@ export const users = sqliteTable(
|
||||
.default("client"),
|
||||
phone: text("phone"),
|
||||
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" })
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
@ -33,6 +35,9 @@ export const users = sqliteTable(
|
||||
emailIdx: index("users_email_idx").on(table.email),
|
||||
gymIdIdx: index("users_gym_id_idx").on(table.gymId),
|
||||
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;
|
||||
gymId?: string;
|
||||
imageUrl?: string;
|
||||
expoPushToken?: string;
|
||||
deviceType?: "ios" | "android";
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user