This commit is contained in:
Rotimi Best
2026-06-23 10:12:40 +01:00
4 changed files with 121 additions and 22 deletions
@@ -8,6 +8,7 @@
import * as Table from '@cio/ui/base/table'; import * as Table from '@cio/ui/base/table';
import { courseApi } from '$features/course/api'; import { courseApi } from '$features/course/api';
import { calDateDiff } from '$lib/utils/functions/date';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
import EmptyState from './empty-state.svelte'; import EmptyState from './empty-state.svelte';
@@ -125,7 +126,7 @@
</Table.Cell> </Table.Cell>
<Table.Cell class="min-w-[120px] px-4 py-3"> <Table.Cell class="min-w-[120px] px-4 py-3">
<span class="ui:text-muted-foreground text-sm"> <span class="ui:text-muted-foreground text-sm">
{student.lastSeen} {student.lastSeen ? calDateDiff(student.lastSeen) : $t('analytics.a_while_ago')}
</span> </span>
</Table.Cell> </Table.Cell>
<Table.Cell class="min-w-[120px] px-4 py-3"> <Table.Cell class="min-w-[120px] px-4 py-3">
@@ -21,7 +21,7 @@ export interface StudentOverview {
exercisesSubmitted: number; exercisesSubmitted: number;
totalExercises: number; totalExercises: number;
averageGrade: number; averageGrade: number;
lastSeen: string; lastSeen: string | undefined;
progressPercentage: number; progressPercentage: number;
} }
+13 -8
View File
@@ -19,6 +19,7 @@ import {
} from '@cio/db/queries/group'; } from '@cio/db/queries/group';
import { import {
getLastLogin, getLastLogin,
getLastSeenForUserIds,
getLessonsWithCompletion, getLessonsWithCompletion,
getProfileCourseProgress, getProfileCourseProgress,
getUserExercisesStats getUserExercisesStats
@@ -33,7 +34,7 @@ import { updateExercise, updateExercisesSectionId } from '@cio/db/queries/exerci
import { getProfileById } from '@cio/db/queries/auth'; import { getProfileById } from '@cio/db/queries/auth';
import { insertOrganizationMembersOnConflictDoNothing } from '@cio/db/queries/organization'; import { insertOrganizationMembersOnConflictDoNothing } from '@cio/db/queries/organization';
import { annotateCourseContentWithProgression } from './progression'; 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'; import { guardCourseTypeTransition } from './public-course-guard';
const DEFAULT_CONTENT_GROUPING = true; 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); const exercises = course.contentItems.filter((item) => item.type === ContentType.Exercise);
// Get student analytics // 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( const studentAnalytics = await Promise.all(
students students
.filter((student) => student.profileId !== null) .filter((student) => student.profileId !== null)
.map(async (student) => { .map(async (student) => {
try { try {
const [courseProgress, userExercisesStats, lastLoginDate] = await Promise.all([ const [courseProgress, userExercisesStats] = await Promise.all([
getCourseProgressQuery(courseId, student.profileId!), getCourseProgressQuery(courseId, student.profileId!),
getUserExercisesStats(courseId, student.profileId!), getUserExercisesStats(courseId, student.profileId!)
getLastLogin(student.profileId!)
]); ]);
if (!courseProgress) { if (!courseProgress) {
@@ -464,6 +469,7 @@ export async function getCourseAnalytics(courseId: string) {
const lessonsCompleted = courseProgress.lessonsCompleted || 0; const lessonsCompleted = courseProgress.lessonsCompleted || 0;
const totalLessons = courseProgress.lessonsCount || 0; const totalLessons = courseProgress.lessonsCount || 0;
const progressPercentage = calcPercentageWithRounding(lessonsCompleted, totalLessons); const progressPercentage = calcPercentageWithRounding(lessonsCompleted, totalLessons);
const lastSeen = lastSeenByProfileId.get(student.profileId!) ?? undefined;
return { return {
id: student.profileId, id: student.profileId,
@@ -477,7 +483,7 @@ export async function getCourseAnalytics(courseId: string) {
exercisesSubmitted: completedExercises, exercisesSubmitted: completedExercises,
totalExercises, totalExercises,
averageGrade, averageGrade,
lastSeen: formatLastSeen(lastLoginDate), lastSeen,
progressPercentage progressPercentage
}; };
} catch (error) { } 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); throw new AppError('User profile not found', ErrorCodes.PROFILE_NOT_FOUND, 404);
} }
// Get last login const lastSeen = await getLastLogin(userId);
const lastLoginDate = await getLastLogin(userId);
// Fetch user exercises stats, lessons with completion, and course progress // Fetch user exercises stats, lessons with completion, and course progress
const [userExercisesStats, lessons, courseProgress] = await Promise.all([ const [userExercisesStats, lessons, courseProgress] = await Promise.all([
@@ -582,7 +587,7 @@ export async function getUserCourseAnalytics(courseId: string, userId: string) {
fullName: profile.fullname || '', fullName: profile.fullname || '',
email: profile.email || '', email: profile.email || '',
avatarUrl: profile.avatarUrl || '', avatarUrl: profile.avatarUrl || '',
lastSeen: formatLastSeen(lastLoginDate) lastSeen: lastSeen || undefined
}, },
averageGrade, averageGrade,
userExercisesStats, userExercisesStats,
+105 -12
View File
@@ -4,29 +4,122 @@ import { and, desc, eq, inArray, or, sql } from 'drizzle-orm';
import { db } from '@db/drizzle'; 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 | Date | null | undefined>): 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 * @param userId User ID
* @returns Last login timestamp or undefined * @returns Last seen timestamp or null
*/ */
export async function getLastLogin(userId: string): Promise<string | null> { export async function getLastLogin(userId: string): Promise<string | null> {
try { try {
const [loginEvent] = await db const [[loginEvent], [sessionEvent]] = await Promise.all([
.select({ db
loggedInAt: schema.analyticsLoginEvents.loggedInAt .select({
}) loggedInAt: schema.analyticsLoginEvents.loggedInAt
.from(schema.analyticsLoginEvents) })
.where(eq(schema.analyticsLoginEvents.userId, userId)) .from(schema.analyticsLoginEvents)
.orderBy(desc(schema.analyticsLoginEvents.loggedInAt)) .where(eq(schema.analyticsLoginEvents.userId, userId))
.limit(1); .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) { } catch (error) {
console.error('getLastLoginAt error:', error); console.error('getLastLogin error:', error);
return null; 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<Map<string, string | null>> {
const uniqueUserIds = [...new Set(userIds)];
const lastSeenByUserId = new Map<string, string | null>(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<string>`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<string>`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 * Gets user exercise statistics for a course
* @param courseId Course ID * @param courseId Course ID