Files
fitaiProto/apps/admin/src/app/api/users/statistics/route.ts
T
2026-03-11 06:07:16 +01:00

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