notification system implemented

need refinements
This commit is contained in:
echo 2026-03-11 06:07:16 +01:00
parent 612259f020
commit 1143f8ca02
33 changed files with 2221 additions and 222 deletions

Binary file not shown.

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

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

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

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

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

View File

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

View File

@ -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 },

View File

@ -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(

View File

@ -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) {

View File

@ -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,
};
}
}

View File

@ -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;

View File

@ -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(),

View File

@ -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"
}

View File

@ -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",

View File

@ -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",

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

View File

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

View File

@ -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) {

View File

@ -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)}

View File

@ -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,

View File

@ -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>
);

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

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

View File

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

View File

@ -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]);

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

View File

@ -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,
}}
>

View 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
next.md Normal file
View File

@ -0,0 +1 @@
automated daily recommendation

View File

@ -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;

View File

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

View File

@ -25,6 +25,8 @@ export interface User {
role: UserRole;
gymId?: string;
imageUrl?: string;
expoPushToken?: string;
deviceType?: "ios" | "android";
createdAt: Date;
updatedAt: Date;
}