Merge branch 'main' of https://github.com/classroomio/classroomio
This commit is contained in:
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user