From 1143f8ca021cb23cbdd4a0aa533c980bfe45e4db Mon Sep 17 00:00:00 2001 From: echo Date: Wed, 11 Mar 2026 06:07:16 +0100 Subject: [PATCH] notification system implemented need refinements --- apps/admin/data/fitai.db | Bin 86016 -> 172032 bytes .../src/app/api/notifications/[id]/route.ts | 105 +++++ .../api/notifications/mark-all-read/route.ts | 37 ++ apps/admin/src/app/api/notifications/route.ts | 116 +++++ .../app/api/notifications/save-token/route.ts | 71 +++ .../api/notifications/unread-count/route.ts | 35 ++ .../app/api/recommendations/approve/route.ts | 120 +++-- .../app/api/recommendations/generate/route.ts | 43 +- .../src/app/api/users/statistics/route.ts | 139 ++++-- .../src/components/users/Recommendations.tsx | 5 +- apps/admin/src/lib/database/drizzle.ts | 92 ++++ apps/admin/src/lib/database/types.ts | 20 +- apps/admin/src/lib/validation/schemas.ts | 2 +- apps/mobile/app.json | 23 +- apps/mobile/package-lock.json | 39 ++ apps/mobile/package.json | 1 + apps/mobile/src/api/notifications.ts | 209 +++++++++ apps/mobile/src/api/recommendations.ts | 8 +- apps/mobile/src/app/(tabs)/attendance.tsx | 10 + apps/mobile/src/app/(tabs)/index.tsx | 84 +++- .../mobile/src/app/(tabs)/recommendations.tsx | 56 ++- apps/mobile/src/app/_layout.tsx | 38 +- .../src/components/NotificationsModal.tsx | 434 ++++++++++++++++++ .../mobile/src/components/NutritionWidget.tsx | 124 +++++ .../mobile/src/components/QuickActionGrid.tsx | 236 +++++----- .../src/components/WeeklyProgressWidget.tsx | 16 + .../src/contexts/NotificationsContext.tsx | 200 ++++++++ .../mobile/src/contexts/StatisticsContext.tsx | 39 +- .../src/hooks/useNotificationPermissions.ts | 126 +++++ next.md | 1 + packages/database/drizzle.config.ts | 7 +- packages/database/src/schema.ts | 5 + packages/shared/src/types/index.ts | 2 + 33 files changed, 2221 insertions(+), 222 deletions(-) create mode 100644 apps/admin/src/app/api/notifications/[id]/route.ts create mode 100644 apps/admin/src/app/api/notifications/mark-all-read/route.ts create mode 100644 apps/admin/src/app/api/notifications/route.ts create mode 100644 apps/admin/src/app/api/notifications/save-token/route.ts create mode 100644 apps/admin/src/app/api/notifications/unread-count/route.ts create mode 100644 apps/mobile/src/api/notifications.ts create mode 100644 apps/mobile/src/components/NotificationsModal.tsx create mode 100644 apps/mobile/src/components/NutritionWidget.tsx create mode 100644 apps/mobile/src/contexts/NotificationsContext.tsx create mode 100644 apps/mobile/src/hooks/useNotificationPermissions.ts create mode 100644 next.md diff --git a/apps/admin/data/fitai.db b/apps/admin/data/fitai.db index 8a600064619b12374d5268c3171ccba6883cd865..0fc1c97010a7341c65e61e2816297efcb209ec6e 100644 GIT binary patch delta 9018 zcmd5>Yj7LY72dU0mgLtuPvvEDod>dr<=B>GTVa40$4NbjlilzY500(W2HqER7OS~V_yCHp>moc$Mjn*AMnaeCgyqilDrVu1>f_f);DyU)d z@1F?Nn4Y?gcxt+_{+-I{k6vBmSplwE#K6bG3gS8^+d;8M*c0qt_K$1_xM~$U*yI?Z zOJKr7b?h404PD0q#+T9Uu4R!@WaiJ^F(bg&i zxHumVw8n8P+}Vv=+qn>vKl1hu+!b{fDR!jpA}Bdq*E!*-STHa^`B;jk=P?Ykl3q$N zOsCMv$AxgLy}c86@nInt>WK3=+|?NhaY3Pt2REbq+aJ{4b`{D+rN{=^4&G>KCm*34qP{reA-apIo} z_9VrgXHQPg+xc^L?Hb1b{Y@rTj=C?qPr3J3K3y4g zROK7%PgWgnr(IsJmwvQ&`t)aK>A_}%_h;ltCZ{ALsyvJ(w7c*A-OWhAqau$Z>Uak0 zBdGU#eZ5zI6tr9;zCGk>A;O;rb{pZVfR zNB$?Z>!)9=`m7@VQ}$l1){Q6+lAx&QF@-{TkkF=>=EQKu(THnh} z4ZC~p-qvSxOqC}p3Q?ru{VLiy*gw!SxCd?R+k>|6*p0RiZQIri1T%^Zy5c!5E$1XP zs-Z0AQg*IfHj5>G+!Vg4Z*$Mkw%y1#v}=>kMrgXskX<0lU`Y_Agq_q}Pb7`iq->c@ z%+V}c3-1|;l8O^JYg3is!1J6MwI<2x1g!8`F{6sIWSUyMJ|5NuyFoFNO=2!X0#wkh zM6h|sU|;{1?Yd8!iahB@gMFL(2K%=6_U%IExDst5FT+oQcWCD(66KPNIG&hB#y0hB z>w__#Q+Q6mRenghQ@sr|1tB*19!oFKtk)fBvwp3Za4vFrdwujoKo3MoR>io;b3~*P z$tgG+fd%T(Xq#GQ4@ujcoy~|#oj|h$lSI&E!)ltNL_yR)vJesi>@X6-F0iZ#ORr{v z#ZjbP)iX^X1v!PfkUbp|?@I05-=c6B9 zq^G1!iS1#rJvnW2AaO1kYS^U}XM~jys;H)LnZQnCMd1?VX2=myhCz3!l{gTKMmop2 zl!DES(9G=uD6f(-_KdK?#GVgi^jfnaJfErb4)`YDVd*6eHYY~fY>(cPXDVrqW@>Vj z``8Js+k>ZM z*3N1KPzedVZ_U~z3a*)`Z*Y10!Ju$igDeq7Fk4TixvQ>jkwtCr>LzJXYtR%yqS_iN z*mRg)?((i%M?VtQquZJ{SxO#}J;F<7OJZER(lKPQSvJf*j129@{!}>Cig$A;mf^*f z3GSk8UMs|S5vM(9OD*}f!3)RGi~?;B_`75!H&g+UZ#u#*7X~5eg4c8g-Laz5d(S#O z=^8D!m_ZO}D;a2fwVq~L-4*oh6n%TO7n+In^n&~zNBz*i%ENt6hL7!SV3Z6dvj?tCaOuqp%|CqBpwS$;KbB8%D@vyMOaFbVMDT_LXnkFlarUW(u=e~oW|)G z&NjmuBt=wIQh*^4J&eZ@ry@>*uLN#}AG&3}EJlJ%)&w?lYRgWXjmz0I=#J-7sX$B? z#?hFZ9hP&dVN?()SXGc5hvf&_+M(*pCd8Ilz# z`CpxKx9h=^!VoJEiYgW*5)7oN-A~MlK_nB=hDqZPxT=!h`}%tS7`O!oAQeOsWDv_O zXj@L=lN->Mte8rHyZNNZLq9pWZ-8Fr8p(;0%r)olJhL?a)N6~j=vpD(IB1(uEGzT8 zoDz-K2oa#7j10!hEohg-@xw$W#>3#|L@sMa7DR1U#Q8&K*5v)C=SK}Ixl!mr$vSw* z8W61H5?oe~83o?447?<<5)jm|q$~v8D9(u2 z*MKAge?q20;ZF+l9&?LoME^Yg#L+z@-Fx~28F>t6!D2Jxb>oVdk{n46h&0Pq6dO09 z&fGIwP;V0R!$?#SNj^y)YgER;ziBZz_4(zA9kgk;n`sjSj%({|3v_e|@DcCq3Umvh zU?3Fiz~NY|I}Aw6|AXBuY~KbHa)JVo=mbDhKV$b2>}M{XukbmBJabv%Fzs^(4hQWs2Qjd!%-#=5I9zYrItp zJb&=?xLIC=Y>+VE=Vj=c0ltzK2?C3mC*J0gvaKH;~OkyoT8U8+9W zR3gZKaJxq*r*pH5lO>idZnhoZ|kSeUMLpLloW|qsu6J-j>z;_mycrA~{JM$>EYZ?^g3I4sGwhXQ+VG*@;X}XqgF8XP?u7G;4^26RFvo zXVt{oN|(0>3|m>oFePHRn*^{W$t+9P6o;}lOLr0D6>YPs=WpLBY8H-JV&vRf#aS-7 z=1wtL)Y)Ept2meU<}d0Hw9-}1>JYxWwvy}^S~+ynvdIdx@2IULNvo9tk~3W&Y0641 zv3FPm%H9WP=EVX*GzviRcXJ_Nf%GQ0ZQKXQ zH|;`Ty|>ca+n~4QXxHJyk!k}Vn#-*; z=@atT2@s`Zg}?)CtqAHP72uxT*Mky40}d#Wro%)x2qn_{WYQ#asT3N+1mXdJAaOO^ zN1F@qbE9yd=GjP(Rvzk^h4eIkGr$8t8Gv7awP76+G9U)PcX|kzWq>#UU5Lss8Uw^f zM|XH`K^Y_`|J-?YjpKcZmILSZx`g&b1s5zqi zzc(uiTxQ$;(2P-vQy6HJ2oo2OWae*U;BVt!&wpdHphE>eiw1Kj<8(hqMwQ9y&WEuv z@>eqOR{~}7`NbMp7=?X(8Pug27&#bNSQsQZB!SfA2k#XoFTZaql9*hQS(cicnvz+X zn`s@_3c{Nj9`FMdCzmK> zsxv8o1BC+=FuWWJ4D4+C8Te20l=7HyBy(u6?PvcE^z%x#?fcmn6B${6iZ~`7wBMf0 z!6?MCi7||Ma>6&$Z32ue25gLM#tdx6K*gGDf|I?N1wwu0TU`4sC&o$xb#3=?WL(K9 ST#%ZVl9`vDx#7rW7zO~`t$;58 diff --git a/apps/admin/src/app/api/notifications/[id]/route.ts b/apps/admin/src/app/api/notifications/[id]/route.ts new file mode 100644 index 0000000..0d06eb0 --- /dev/null +++ b/apps/admin/src/app/api/notifications/[id]/route.ts @@ -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 }, + ); + } +} diff --git a/apps/admin/src/app/api/notifications/mark-all-read/route.ts b/apps/admin/src/app/api/notifications/mark-all-read/route.ts new file mode 100644 index 0000000..4f182ee --- /dev/null +++ b/apps/admin/src/app/api/notifications/mark-all-read/route.ts @@ -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 }, + ); + } +} diff --git a/apps/admin/src/app/api/notifications/route.ts b/apps/admin/src/app/api/notifications/route.ts new file mode 100644 index 0000000..e297d72 --- /dev/null +++ b/apps/admin/src/app/api/notifications/route.ts @@ -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 }, + ); + } +} diff --git a/apps/admin/src/app/api/notifications/save-token/route.ts b/apps/admin/src/app/api/notifications/save-token/route.ts new file mode 100644 index 0000000..ceefbae --- /dev/null +++ b/apps/admin/src/app/api/notifications/save-token/route.ts @@ -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 }, + ); + } +} diff --git a/apps/admin/src/app/api/notifications/unread-count/route.ts b/apps/admin/src/app/api/notifications/unread-count/route.ts new file mode 100644 index 0000000..3f8e244 --- /dev/null +++ b/apps/admin/src/app/api/notifications/unread-count/route.ts @@ -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 }, + ); + } +} diff --git a/apps/admin/src/app/api/recommendations/approve/route.ts b/apps/admin/src/app/api/recommendations/approve/route.ts index 476ed27..3f46f2c 100644 --- a/apps/admin/src/app/api/recommendations/approve/route.ts +++ b/apps/admin/src/app/api/recommendations/approve/route.ts @@ -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 }, + ); + } } diff --git a/apps/admin/src/app/api/recommendations/generate/route.ts b/apps/admin/src/app/api/recommendations/generate/route.ts index cd78bd8..e46ab19 100644 --- a/apps/admin/src/app/api/recommendations/generate/route.ts +++ b/apps/admin/src/app/api/recommendations/generate/route.ts @@ -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 }, diff --git a/apps/admin/src/app/api/users/statistics/route.ts b/apps/admin/src/app/api/users/statistics/route.ts index 208fbb2..70acdf9 100644 --- a/apps/admin/src/app/api/users/statistics/route.ts +++ b/apps/admin/src/app/api/users/statistics/route.ts @@ -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(); 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( diff --git a/apps/admin/src/components/users/Recommendations.tsx b/apps/admin/src/components/users/Recommendations.tsx index cade2d4..5052706 100644 --- a/apps/admin/src/components/users/Recommendations.tsx +++ b/apps/admin/src/components/users/Recommendations.tsx @@ -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) { diff --git a/apps/admin/src/lib/database/drizzle.ts b/apps/admin/src/lib/database/drizzle.ts index 2e6faf1..b0e6a35 100644 --- a/apps/admin/src/lib/database/drizzle.ts +++ b/apps/admin/src/lib/database/drizzle.ts @@ -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, + ): Promise { + 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 { + 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 { + const result = await this.db + .select({ count: sql`count(*)` }) + .from(notifications) + .where( + and(eq(notifications.userId, userId), eq(notifications.read, false)), + ); + + return Number(result[0]?.count || 0); + } + + async markNotificationAsRead(id: string): Promise { + 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 { + 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 { + const result = await this.db + .delete(notifications) + .where(eq(notifications.id, id)); + + return result.changes > 0; + } + + private mapNotification(row: Record): 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, + }; + } } diff --git a/apps/admin/src/lib/database/types.ts b/apps/admin/src/lib/database/types.ts index a2afaab..32c4875 100644 --- a/apps/admin/src/lib/database/types.ts +++ b/apps/admin/src/lib/database/types.ts @@ -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; completeGoal(id: string): Promise; + // Notification operations + createNotification( + notification: Omit, + ): Promise; + getNotificationsByUserId(userId: string): Promise; + getUnreadNotificationCount(userId: string): Promise; + markNotificationAsRead(id: string): Promise; + markAllNotificationsAsRead(userId: string): Promise; + deleteNotification(id: string): Promise; + // Dashboard operations getDashboardStats(): Promise<{ totalUsers: number; diff --git a/apps/admin/src/lib/validation/schemas.ts b/apps/admin/src/lib/validation/schemas.ts index a0241ce..188d2f2 100644 --- a/apps/admin/src/lib/validation/schemas.ts +++ b/apps/admin/src/lib/validation/schemas.ts @@ -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(), diff --git a/apps/mobile/app.json b/apps/mobile/app.json index 94f2258..89b0386 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -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,8 +32,16 @@ "plugins": [ "expo-router", "expo-font", - "expo-barcode-scanner" + "expo-barcode-scanner", + [ + "expo-notifications", + { + "icon": "./assets/icon.png", + "color": "#ffffff", + "sounds": [] + } + ] ], "scheme": "fitai" } -} \ No newline at end of file +} diff --git a/apps/mobile/package-lock.json b/apps/mobile/package-lock.json index 038822f..5c087ac 100644 --- a/apps/mobile/package-lock.json +++ b/apps/mobile/package-lock.json @@ -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", diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 9d952d8..950b32f 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -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", diff --git a/apps/mobile/src/api/notifications.ts b/apps/mobile/src/api/notifications.ts new file mode 100644 index 0000000..22c32bd --- /dev/null +++ b/apps/mobile/src/api/notifications.ts @@ -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 { + success: boolean; + data: T; + meta?: { + timestamp: string; + count?: number; + }; +} + +/** + * Fetch all notifications for the authenticated user + */ +export async function fetchNotifications( + token: string | null, +): Promise { + const headers: Record = { + "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 = 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 { + const headers: Record = { + "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 { + const headers: Record = { + "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 = 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 { + const headers: Record = { + "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 { + const headers: Record = { + "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 { + const headers: Record = { + "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}`); + } +} diff --git a/apps/mobile/src/api/recommendations.ts b/apps/mobile/src/api/recommendations.ts index 79e0de3..4186ab5 100644 --- a/apps/mobile/src/api/recommendations.ts +++ b/apps/mobile/src/api/recommendations.ts @@ -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 { 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, + }), }, ); diff --git a/apps/mobile/src/app/(tabs)/attendance.tsx b/apps/mobile/src/app/(tabs)/attendance.tsx index f4e889b..a8c9d64 100644 --- a/apps/mobile/src/app/(tabs)/attendance.tsx +++ b/apps/mobile/src/app/(tabs)/attendance.tsx @@ -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(null); const [history, setHistory] = useState([]); @@ -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) { diff --git a/apps/mobile/src/app/(tabs)/index.tsx b/apps/mobile/src/app/(tabs)/index.tsx index db2a0a5..b3f2685 100644 --- a/apps/mobile/src/app/(tabs)/index.tsx +++ b/apps/mobile/src/app/(tabs)/index.tsx @@ -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 */} + {/* Quick Action Grid */} + setTrackMealModalVisible(true)} + onAddWaterPress={() => setAddWaterModalVisible(true)} + onScanFoodPress={() => setScanFoodModalVisible(true)} + onLogWorkoutPress={() => { + // TODO: Implement workout logging + console.log("Log workout tapped"); + }} + /> + + {/* Nutrition Widget */} + + + {/* Hydration Widget */} + + + {/* Weekly Progress Widget */} + + setTrackMealModalVisible(false)} diff --git a/apps/mobile/src/app/(tabs)/recommendations.tsx b/apps/mobile/src/app/(tabs)/recommendations.tsx index 9cd0cdd..bc6112e 100644 --- a/apps/mobile/src/app/(tabs)/recommendations.tsx +++ b/apps/mobile/src/app/(tabs)/recommendations.tsx @@ -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 ( + - + - + {unreadCount > 0 && ( + + {unreadCount} + + )} + {/* 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, diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 40e5c1f..0ea3b95 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -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 ( + + + + + + ); +} + // Validate environment variables on app startup try { const env = validateEnv(); @@ -153,17 +171,15 @@ export default function RootLayout() { return ( - - - - - - - - - - - + + + + + + + + + ); diff --git a/apps/mobile/src/components/NotificationsModal.tsx b/apps/mobile/src/components/NotificationsModal.tsx new file mode 100644 index 0000000..2c4f3f7 --- /dev/null +++ b/apps/mobile/src/components/NotificationsModal.tsx @@ -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 ( + + + + + {/* Header */} + + + + Notifications + + {notifications.filter((n) => !n.read).length} unread + + + + + + + + + {/* Actions Bar */} + {notifications.length > 0 && ( + + !n.read).length === 0} + > + !n.read).length === 0 && + styles.actionTextDisabled, + ]} + > + Mark all as read + + + + )} + + {/* Notifications List */} + {loading ? ( + + + + ) : notifications.length === 0 ? ( + + + + No Notifications + + You're all caught up! New notifications will appear here. + + + + ) : ( + item.id} + renderItem={({ item }) => ( + + )} + contentContainerStyle={styles.listContent} + /> + )} + + + + ); +} + +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 ( + !notification.read && onMarkAsRead(notification.id)} + activeOpacity={0.7} + > + + + {/* Icon */} + + + + + {/* Content */} + + + {notification.title} + + + {notification.message} + + + {formatTime(notification.createdAt)} + + + + {/* Actions */} + onDelete(notification.id)} + style={styles.deleteButton} + > + + + + + {/* Unread Indicator */} + {!notification.read && } + + + ); +} + +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, + }, +}); diff --git a/apps/mobile/src/components/NutritionWidget.tsx b/apps/mobile/src/components/NutritionWidget.tsx new file mode 100644 index 0000000..9f423ce --- /dev/null +++ b/apps/mobile/src/components/NutritionWidget.tsx @@ -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 ( + + + + + + + + + + + Nutrition + + {current} / {goal} kcal + + + + + + {Math.round(percentage * 100)}% + + + + + + + + + + + + ); +} + +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, + }, +}); diff --git a/apps/mobile/src/components/QuickActionGrid.tsx b/apps/mobile/src/components/QuickActionGrid.tsx index 0064e4a..7a1ccfa 100644 --- a/apps/mobile/src/components/QuickActionGrid.tsx +++ b/apps/mobile/src/components/QuickActionGrid.tsx @@ -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 ( - - - - - - {label} - - - - ); + return ( + + + + + + {label} + + + + ); } interface QuickActionGridProps { - onTrackMealPress?: () => void; - onAddWaterPress?: () => void; - onScanFoodPress?: () => void; + onTrackMealPress?: () => void; + onAddWaterPress?: () => void; + onScanFoodPress?: () => void; + onLogWorkoutPress?: () => void; } -export function QuickActionGrid({ onTrackMealPress, onAddWaterPress, onScanFoodPress }: QuickActionGridProps) { - return ( - - Quick Actions - - - - - - - - ); +export function QuickActionGrid({ + onTrackMealPress, + onAddWaterPress, + onScanFoodPress, + onLogWorkoutPress, +}: QuickActionGridProps) { + return ( + + Quick Actions + + + + + + + + ); } 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, + }, }); diff --git a/apps/mobile/src/components/WeeklyProgressWidget.tsx b/apps/mobile/src/components/WeeklyProgressWidget.tsx index f2fefbe..5cdbb35 100644 --- a/apps/mobile/src/components/WeeklyProgressWidget.tsx +++ b/apps/mobile/src/components/WeeklyProgressWidget.tsx @@ -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]); diff --git a/apps/mobile/src/contexts/NotificationsContext.tsx b/apps/mobile/src/contexts/NotificationsContext.tsx new file mode 100644 index 0000000..f2159e2 --- /dev/null +++ b/apps/mobile/src/contexts/NotificationsContext.tsx @@ -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; + markNotificationAsRead: (id: string) => Promise; + markAllAsReadAction: () => Promise; + deleteNotificationAction: (id: string) => Promise; +} + +const NotificationsContext = createContext< + NotificationsContextType | undefined +>(undefined); + +export function NotificationsProvider({ + children, +}: { + children: React.ReactNode; +}) { + const { getToken, isSignedIn } = useAuth(); + const [notifications, setNotifications] = useState([]); + 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 ( + + {children} + + ); +} + +export function useNotifications() { + const context = useContext(NotificationsContext); + if (context === undefined) { + throw new Error( + "useNotifications must be used within a NotificationsProvider", + ); + } + return context; +} diff --git a/apps/mobile/src/contexts/StatisticsContext.tsx b/apps/mobile/src/contexts/StatisticsContext.tsx index 68f7567..d5f60ba 100644 --- a/apps/mobile/src/contexts/StatisticsContext.tsx +++ b/apps/mobile/src/contexts/StatisticsContext.tsx @@ -9,6 +9,7 @@ interface StatisticsContextValue { loading: boolean; error: Error | null; refetchStatistics: () => Promise; + forceRefresh: () => Promise; 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 ( diff --git a/apps/mobile/src/hooks/useNotificationPermissions.ts b/apps/mobile/src/hooks/useNotificationPermissions.ts new file mode 100644 index 0000000..f9370af --- /dev/null +++ b/apps/mobile/src/hooks/useNotificationPermissions.ts @@ -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( + undefined, + ); + const responseListener = useRef( + 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]); +} diff --git a/next.md b/next.md new file mode 100644 index 0000000..2df5ba3 --- /dev/null +++ b/next.md @@ -0,0 +1 @@ +automated daily recommendation diff --git a/packages/database/drizzle.config.ts b/packages/database/drizzle.config.ts index 719fa18..a48c1e1 100644 --- a/packages/database/drizzle.config.ts +++ b/packages/database/drizzle.config.ts @@ -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; diff --git a/packages/database/src/schema.ts b/packages/database/src/schema.ts index a2270d0..3924ee6 100644 --- a/packages/database/src/schema.ts +++ b/packages/database/src/schema.ts @@ -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, + ), }), ); diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 2e2e9b4..50dd9a1 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -25,6 +25,8 @@ export interface User { role: UserRole; gymId?: string; imageUrl?: string; + expoPushToken?: string; + deviceType?: "ios" | "android"; createdAt: Date; updatedAt: Date; }