diff --git a/apps/dashboard/src/lib/features/course/components/analytics/student-table.svelte b/apps/dashboard/src/lib/features/course/components/analytics/student-table.svelte index bca7c969..d890c678 100644 --- a/apps/dashboard/src/lib/features/course/components/analytics/student-table.svelte +++ b/apps/dashboard/src/lib/features/course/components/analytics/student-table.svelte @@ -8,6 +8,7 @@ import * as Table from '@cio/ui/base/table'; import { courseApi } from '$features/course/api'; + import { calDateDiff } from '$lib/utils/functions/date'; import { t } from '$lib/utils/functions/translations'; import EmptyState from './empty-state.svelte'; @@ -125,7 +126,7 @@ - {student.lastSeen} + {student.lastSeen ? calDateDiff(student.lastSeen) : $t('analytics.a_while_ago')} diff --git a/apps/dashboard/src/lib/utils/types/analytics.ts b/apps/dashboard/src/lib/utils/types/analytics.ts index 6da3ebb3..a68f8314 100644 --- a/apps/dashboard/src/lib/utils/types/analytics.ts +++ b/apps/dashboard/src/lib/utils/types/analytics.ts @@ -21,7 +21,7 @@ export interface StudentOverview { exercisesSubmitted: number; totalExercises: number; averageGrade: number; - lastSeen: string; + lastSeen: string | undefined; progressPercentage: number; } diff --git a/packages/core/src/services/course/course.ts b/packages/core/src/services/course/course.ts index 4ac79d30..e80f8a33 100644 --- a/packages/core/src/services/course/course.ts +++ b/packages/core/src/services/course/course.ts @@ -19,6 +19,7 @@ import { } from '@cio/db/queries/group'; import { getLastLogin, + getLastSeenForUserIds, getLessonsWithCompletion, getProfileCourseProgress, getUserExercisesStats @@ -33,7 +34,7 @@ import { updateExercise, updateExercisesSectionId } from '@cio/db/queries/exerci import { getProfileById } from '@cio/db/queries/auth'; import { insertOrganizationMembersOnConflictDoNothing } from '@cio/db/queries/organization'; import { annotateCourseContentWithProgression } from './progression'; -import { buildCourseContent, calcPercentageWithRounding, formatLastSeen, type CourseContent } from './utils'; +import { buildCourseContent, calcPercentageWithRounding, type CourseContent } from './utils'; import { guardCourseTypeTransition } from './public-course-guard'; const DEFAULT_CONTENT_GROUPING = true; @@ -439,15 +440,19 @@ export async function getCourseAnalytics(courseId: string) { const exercises = course.contentItems.filter((item) => item.type === ContentType.Exercise); // Get student analytics + const studentProfileIds = students + .map((student) => student.profileId) + .filter((profileId): profileId is string => profileId !== null); + const lastSeenByProfileId = await getLastSeenForUserIds(studentProfileIds); + const studentAnalytics = await Promise.all( students .filter((student) => student.profileId !== null) .map(async (student) => { try { - const [courseProgress, userExercisesStats, lastLoginDate] = await Promise.all([ + const [courseProgress, userExercisesStats] = await Promise.all([ getCourseProgressQuery(courseId, student.profileId!), - getUserExercisesStats(courseId, student.profileId!), - getLastLogin(student.profileId!) + getUserExercisesStats(courseId, student.profileId!) ]); if (!courseProgress) { @@ -464,6 +469,7 @@ export async function getCourseAnalytics(courseId: string) { const lessonsCompleted = courseProgress.lessonsCompleted || 0; const totalLessons = courseProgress.lessonsCount || 0; const progressPercentage = calcPercentageWithRounding(lessonsCompleted, totalLessons); + const lastSeen = lastSeenByProfileId.get(student.profileId!) ?? undefined; return { id: student.profileId, @@ -477,7 +483,7 @@ export async function getCourseAnalytics(courseId: string) { exercisesSubmitted: completedExercises, totalExercises, averageGrade, - lastSeen: formatLastSeen(lastLoginDate), + lastSeen, progressPercentage }; } catch (error) { @@ -551,8 +557,7 @@ export async function getUserCourseAnalytics(courseId: string, userId: string) { throw new AppError('User profile not found', ErrorCodes.PROFILE_NOT_FOUND, 404); } - // Get last login - const lastLoginDate = await getLastLogin(userId); + const lastSeen = await getLastLogin(userId); // Fetch user exercises stats, lessons with completion, and course progress const [userExercisesStats, lessons, courseProgress] = await Promise.all([ @@ -582,7 +587,7 @@ export async function getUserCourseAnalytics(courseId: string, userId: string) { fullName: profile.fullname || '', email: profile.email || '', avatarUrl: profile.avatarUrl || '', - lastSeen: formatLastSeen(lastLoginDate) + lastSeen: lastSeen || undefined }, averageGrade, userExercisesStats, diff --git a/packages/db/src/queries/analytics/analytics.ts b/packages/db/src/queries/analytics/analytics.ts index 25ed0ee5..0f9acf4e 100644 --- a/packages/db/src/queries/analytics/analytics.ts +++ b/packages/db/src/queries/analytics/analytics.ts @@ -4,29 +4,122 @@ import { and, desc, eq, inArray, or, sql } from 'drizzle-orm'; import { db } from '@db/drizzle'; +function toIsoTimestamp(value: string | Date | null | undefined): string | null { + if (!value) { + return null; + } + + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) { + return null; + } + + return date.toISOString(); +} + +function pickLatestTimestamp(...values: Array): string | null { + let latest: string | null = null; + let latestTime = -Infinity; + + for (const value of values) { + const iso = toIsoTimestamp(value); + if (!iso) { + continue; + } + + const time = new Date(iso).getTime(); + if (time > latestTime) { + latestTime = time; + latest = iso; + } + } + + return latest; +} + /** - * Gets the last login time for a user + * Gets the last seen time for a user from login events and active sessions. * @param userId User ID - * @returns Last login timestamp or undefined + * @returns Last seen timestamp or null */ export async function getLastLogin(userId: string): Promise { try { - const [loginEvent] = await db - .select({ - loggedInAt: schema.analyticsLoginEvents.loggedInAt - }) - .from(schema.analyticsLoginEvents) - .where(eq(schema.analyticsLoginEvents.userId, userId)) - .orderBy(desc(schema.analyticsLoginEvents.loggedInAt)) - .limit(1); + const [[loginEvent], [sessionEvent]] = await Promise.all([ + db + .select({ + loggedInAt: schema.analyticsLoginEvents.loggedInAt + }) + .from(schema.analyticsLoginEvents) + .where(eq(schema.analyticsLoginEvents.userId, userId)) + .orderBy(desc(schema.analyticsLoginEvents.loggedInAt)) + .limit(1), + db + .select({ + updatedAt: schema.session.updatedAt + }) + .from(schema.session) + .where(eq(schema.session.userId, userId)) + .orderBy(desc(schema.session.updatedAt)) + .limit(1) + ]); - return loginEvent?.loggedInAt; + return pickLatestTimestamp(loginEvent?.loggedInAt, sessionEvent?.updatedAt); } catch (error) { - console.error('getLastLoginAt error:', error); + console.error('getLastLogin error:', error); return null; } } +/** + * Batch lookup of last seen timestamps for multiple users. + * @param userIds User IDs + * @returns Map of user ID to last seen ISO timestamp + */ +export async function getLastSeenForUserIds(userIds: string[]): Promise> { + const uniqueUserIds = [...new Set(userIds)]; + const lastSeenByUserId = new Map(uniqueUserIds.map((userId) => [userId, null])); + + if (uniqueUserIds.length === 0) { + return lastSeenByUserId; + } + + try { + const [loginRows, sessionRows] = await Promise.all([ + db + .select({ + userId: schema.analyticsLoginEvents.userId, + lastSeen: sql`MAX(${schema.analyticsLoginEvents.loggedInAt})`.as('last_seen') + }) + .from(schema.analyticsLoginEvents) + .where(inArray(schema.analyticsLoginEvents.userId, uniqueUserIds)) + .groupBy(schema.analyticsLoginEvents.userId), + db + .select({ + userId: schema.session.userId, + lastSeen: sql`MAX(${schema.session.updatedAt})`.as('last_seen') + }) + .from(schema.session) + .where(inArray(schema.session.userId, uniqueUserIds)) + .groupBy(schema.session.userId) + ]); + + for (const row of loginRows) { + const current = lastSeenByUserId.get(row.userId) ?? null; + lastSeenByUserId.set(row.userId, pickLatestTimestamp(current, row.lastSeen)); + } + + for (const row of sessionRows) { + const current = lastSeenByUserId.get(row.userId) ?? null; + lastSeenByUserId.set(row.userId, pickLatestTimestamp(current, row.lastSeen)); + } + + return lastSeenByUserId; + } catch (error) { + console.error('getLastSeenForUserIds error:', error); + return lastSeenByUserId; + } +} + /** * Gets user exercise statistics for a course * @param courseId Course ID