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