431 lines
13 KiB
TypeScript
431 lines
13 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
import { auth } from "@clerk/nextjs/server";
|
|
import Database from "better-sqlite3";
|
|
import log from "@/lib/logger";
|
|
|
|
const db = new Database("./data/fitai.db");
|
|
|
|
interface WeeklyStats {
|
|
week: string; // ISO week start date
|
|
checkIns: number;
|
|
goalsCompleted: number;
|
|
avgProgress: number;
|
|
}
|
|
|
|
interface GoalStats {
|
|
total: number;
|
|
active: number;
|
|
completed: number;
|
|
avgProgress: number;
|
|
byType: Record<string, number>;
|
|
}
|
|
|
|
interface AttendanceStats {
|
|
totalCheckIns: number;
|
|
currentStreak: number;
|
|
longestStreak: number;
|
|
thisWeek: number;
|
|
thisMonth: number;
|
|
recentCheckIns: Array<{
|
|
id: string;
|
|
checkInTime: string; // ISO string
|
|
checkOutTime: string | null;
|
|
type: string;
|
|
duration?: number; // in minutes
|
|
}>;
|
|
}
|
|
|
|
interface DashboardStatistics {
|
|
goals: GoalStats;
|
|
attendance: AttendanceStats;
|
|
weeklyTrend: WeeklyStats[];
|
|
}
|
|
|
|
// GET - Fetch dashboard statistics for authenticated user
|
|
export async function GET(request: NextRequest) {
|
|
try {
|
|
// Log the request details for debugging
|
|
log.debug("Statistics endpoint called", {
|
|
url: request.url,
|
|
hasAuthHeader: !!request.headers.get("authorization"),
|
|
authHeaderPreview: request.headers.get("authorization")?.substring(0, 30),
|
|
});
|
|
|
|
const { userId: authenticatedUserId } = await auth();
|
|
|
|
log.debug("Auth result", {
|
|
authenticatedUserId,
|
|
isAuthenticated: !!authenticatedUserId,
|
|
});
|
|
|
|
if (!authenticatedUserId) {
|
|
log.error("Statistics GET - authentication failed", {
|
|
hasAuthHeader: !!request.headers.get("authorization"),
|
|
url: request.url,
|
|
});
|
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
}
|
|
|
|
// Get target user ID from query params, default to authenticated user
|
|
const { searchParams } = new URL(request.url);
|
|
const userId = searchParams.get("userId") || authenticatedUserId;
|
|
|
|
// Users can only access their own statistics unless they're staff
|
|
// (we could add role checking here if needed)
|
|
|
|
log.debug("Fetching statistics for user", {
|
|
authenticatedUserId,
|
|
targetUserId: userId,
|
|
});
|
|
|
|
// Get goal statistics
|
|
const goals = db
|
|
.prepare(
|
|
`SELECT
|
|
goal_type as goalType,
|
|
status,
|
|
progress,
|
|
target_value as targetValue,
|
|
current_value as currentValue
|
|
FROM fitness_goals
|
|
WHERE user_id = ?`,
|
|
)
|
|
.all(userId) as Array<{
|
|
goalType: string;
|
|
status: string;
|
|
progress: number;
|
|
targetValue: number | null;
|
|
currentValue: number | null;
|
|
}>;
|
|
|
|
const activeGoals = goals.filter((g) => g.status === "active");
|
|
const completedGoals = goals.filter((g) => g.status === "completed");
|
|
|
|
const goalsByType: Record<string, number> = {};
|
|
goals.forEach((goal) => {
|
|
goalsByType[goal.goalType] = (goalsByType[goal.goalType] || 0) + 1;
|
|
});
|
|
|
|
const avgProgress =
|
|
activeGoals.length > 0
|
|
? activeGoals.reduce((sum, g) => sum + (g.progress || 0), 0) /
|
|
activeGoals.length
|
|
: 0;
|
|
|
|
const goalStats: GoalStats = {
|
|
total: goals.length,
|
|
active: activeGoals.length,
|
|
completed: completedGoals.length,
|
|
avgProgress: Math.round(avgProgress),
|
|
byType: goalsByType,
|
|
};
|
|
|
|
// Get attendance statistics
|
|
const attendanceRecords = db
|
|
.prepare(
|
|
`SELECT
|
|
id,
|
|
check_in_time as checkInTime,
|
|
check_out_time as checkOutTime,
|
|
type
|
|
FROM attendance
|
|
WHERE user_id = ?
|
|
ORDER BY check_in_time DESC`,
|
|
)
|
|
.all(userId) as Array<{
|
|
id: string;
|
|
checkInTime: string | number; // Can be ISO string or Unix timestamp
|
|
checkOutTime: string | number | null;
|
|
type: string;
|
|
}>;
|
|
|
|
const totalCheckIns = attendanceRecords.length;
|
|
|
|
// Get recent check-ins (last 10)
|
|
const recentCheckIns = attendanceRecords.slice(0, 10).map((record) => {
|
|
let duration: number | undefined;
|
|
|
|
// 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 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 {
|
|
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,
|
|
};
|
|
});
|
|
|
|
// Calculate this week's check-ins
|
|
const now = new Date();
|
|
const startOfWeek = new Date(now);
|
|
startOfWeek.setDate(now.getDate() - now.getDay()); // Sunday
|
|
startOfWeek.setHours(0, 0, 0, 0);
|
|
|
|
const thisWeekCheckIns = attendanceRecords.filter((r) => {
|
|
// 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) => {
|
|
// 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;
|
|
let longestStreak = 0;
|
|
let tempStreak = 0;
|
|
let lastDate: Date | null = null;
|
|
|
|
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 =
|
|
typeof record.checkInTime === "number"
|
|
? new Date(record.checkInTime * 1000)
|
|
: new Date(record.checkInTime);
|
|
const dateStr = date.toISOString().split("T")[0];
|
|
uniqueDays.add(dateStr);
|
|
});
|
|
|
|
const sortedDays = Array.from(uniqueDays).sort().reverse();
|
|
|
|
for (let i = 0; i < sortedDays.length; i++) {
|
|
const currentDate = new Date(sortedDays[i]);
|
|
|
|
if (lastDate === null) {
|
|
tempStreak = 1;
|
|
currentStreak = 1;
|
|
} else {
|
|
const dayDiff = Math.floor(
|
|
(lastDate.getTime() - currentDate.getTime()) / (1000 * 60 * 60 * 24),
|
|
);
|
|
|
|
if (dayDiff === 1) {
|
|
tempStreak++;
|
|
if (i === 0 || currentStreak > 0) {
|
|
currentStreak = tempStreak;
|
|
}
|
|
} else {
|
|
if (currentStreak === 0) {
|
|
currentStreak = 0;
|
|
}
|
|
tempStreak = 1;
|
|
}
|
|
}
|
|
|
|
longestStreak = Math.max(longestStreak, tempStreak);
|
|
lastDate = currentDate;
|
|
}
|
|
|
|
// Check if streak is still active (last check-in was today or yesterday)
|
|
if (sortedDays.length > 0) {
|
|
const lastCheckIn = new Date(sortedDays[0]);
|
|
const today = new Date();
|
|
today.setHours(0, 0, 0, 0);
|
|
const yesterday = new Date(today);
|
|
yesterday.setDate(yesterday.getDate() - 1);
|
|
|
|
lastCheckIn.setHours(0, 0, 0, 0);
|
|
|
|
if (
|
|
lastCheckIn.getTime() !== today.getTime() &&
|
|
lastCheckIn.getTime() !== yesterday.getTime()
|
|
) {
|
|
currentStreak = 0;
|
|
}
|
|
}
|
|
|
|
const attendanceStats: AttendanceStats = {
|
|
totalCheckIns,
|
|
currentStreak,
|
|
longestStreak,
|
|
thisWeek: thisWeekCheckIns,
|
|
thisMonth: thisMonthCheckIns,
|
|
recentCheckIns,
|
|
};
|
|
|
|
// Calculate weekly trend (last 8 weeks)
|
|
const weeklyTrend: WeeklyStats[] = [];
|
|
for (let i = 7; i >= 0; i--) {
|
|
const weekStart = new Date(now);
|
|
weekStart.setDate(now.getDate() - now.getDay() - i * 7);
|
|
weekStart.setHours(0, 0, 0, 0);
|
|
|
|
const weekEnd = new Date(weekStart);
|
|
weekEnd.setDate(weekStart.getDate() + 7);
|
|
|
|
const weekCheckIns = attendanceRecords.filter((r) => {
|
|
// 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
|
|
FROM fitness_goals
|
|
WHERE user_id = ?
|
|
AND status = 'completed'
|
|
AND completed_date >= ?
|
|
AND completed_date < ?`,
|
|
)
|
|
.get(userId, weekStart.toISOString(), weekEnd.toISOString()) as {
|
|
count: number;
|
|
};
|
|
|
|
const weekActiveGoals = db
|
|
.prepare(
|
|
`SELECT progress
|
|
FROM fitness_goals
|
|
WHERE user_id = ?
|
|
AND status = 'active'
|
|
AND created_at < ?`,
|
|
)
|
|
.all(userId, weekEnd.toISOString()) as Array<{ progress: number }>;
|
|
|
|
const weekAvgProgress =
|
|
weekActiveGoals.length > 0
|
|
? weekActiveGoals.reduce((sum, g) => sum + (g.progress || 0), 0) /
|
|
weekActiveGoals.length
|
|
: 0;
|
|
|
|
weeklyTrend.push({
|
|
week: weekStart.toISOString().split("T")[0],
|
|
checkIns: weekCheckIns,
|
|
goalsCompleted: weekGoalsCompleted.count,
|
|
avgProgress: Math.round(weekAvgProgress),
|
|
});
|
|
}
|
|
|
|
const statistics: DashboardStatistics = {
|
|
goals: goalStats,
|
|
attendance: attendanceStats,
|
|
weeklyTrend,
|
|
};
|
|
|
|
log.debug("Statistics calculated successfully", {
|
|
userId,
|
|
totalGoals: goalStats.total,
|
|
totalCheckIns,
|
|
weeklyTrendLength: weeklyTrend.length,
|
|
weeklyTrendSample: weeklyTrend[0],
|
|
});
|
|
|
|
// Return statistics in standardized format that matches mobile app expectations
|
|
return NextResponse.json({
|
|
success: true,
|
|
data: {
|
|
statistics: {
|
|
userId,
|
|
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: {
|
|
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(
|
|
{
|
|
success: false,
|
|
error: { message: "Failed to fetch statistics" },
|
|
meta: {
|
|
timestamp: new Date().toISOString(),
|
|
requestId: `req_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
|
|
},
|
|
},
|
|
{ status: 500 },
|
|
);
|
|
}
|
|
}
|