feat: course analytics (#574)
* feat: added anaytics for the course page * feat: Added pagination * feat: cleanup * feat: added translations * feat: added fixes * my improvements --------- Co-authored-by: Rotimi Best <[email protected]>
This commit is contained in:
co-authored by
Rotimi Best
parent
f3de6a063e
commit
ec3875bac2
@@ -0,0 +1,281 @@
|
||||
import type { CourseAnalytics, StudentOverview } from '$lib/utils/types/analytics';
|
||||
|
||||
import type { UserExercisesStats } from '$lib/utils/types/analytics';
|
||||
import { fetchProfileCourseProgress } from '$lib/utils/services/courses';
|
||||
import { getServerSupabase } from '$lib/utils/functions/supabase.server';
|
||||
import { json } from '@sveltejs/kit';
|
||||
|
||||
const supabase = getServerSupabase();
|
||||
|
||||
const CACHE_DURATION = 60 * 5; // 5 minutes
|
||||
|
||||
export async function POST({ setHeaders, request }) {
|
||||
const { courseId } = await request.json();
|
||||
|
||||
if (!courseId) {
|
||||
return json({ success: false, message: 'Request is missing required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
setHeaders({
|
||||
'cache-control': `max-age=${CACHE_DURATION}`,
|
||||
'content-type': 'application/json'
|
||||
});
|
||||
|
||||
try {
|
||||
const analytics = await getCourseAnalytics(courseId);
|
||||
return json(analytics, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error('Course analytics error:', error);
|
||||
return json({ error: 'Something went wrong' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
async function getCourseAnalytics(courseId: string): Promise<CourseAnalytics> {
|
||||
const analytics: CourseAnalytics = {
|
||||
totalTutors: 0,
|
||||
totalStudents: 0,
|
||||
totalLessons: 0,
|
||||
totalExercises: 0,
|
||||
lessonCompletionRate: 0,
|
||||
exerciseCompletionRate: 0,
|
||||
averageGrade: 0,
|
||||
students: []
|
||||
};
|
||||
|
||||
// Fetch course basic info and group members
|
||||
const { data: courseData, error: courseError } = await supabase
|
||||
.from('course')
|
||||
.select(
|
||||
`
|
||||
id,
|
||||
title,
|
||||
group(
|
||||
members:groupmember(
|
||||
id,
|
||||
role_id,
|
||||
profile_id,
|
||||
profile(
|
||||
id,
|
||||
fullname,
|
||||
email,
|
||||
avatar_url
|
||||
)
|
||||
)
|
||||
),
|
||||
lessons:lesson(
|
||||
id,
|
||||
title,
|
||||
exercise(id)
|
||||
)
|
||||
`
|
||||
)
|
||||
.eq('id', courseId)
|
||||
.single();
|
||||
|
||||
if (courseError) {
|
||||
throw new Error('Failed to fetch course data');
|
||||
}
|
||||
|
||||
// Count tutors and students
|
||||
const members = (courseData.group as any)?.members || [];
|
||||
analytics.totalTutors = members.filter(
|
||||
(member) => member.role_id === 1 || member.role_id === 2
|
||||
).length;
|
||||
analytics.totalStudents = members.filter((member) => member.role_id === 3).length;
|
||||
|
||||
// Count lessons and exercises
|
||||
analytics.totalLessons = courseData.lessons?.length || 0;
|
||||
analytics.totalExercises =
|
||||
courseData.lessons?.reduce((total, lesson) => total + (lesson.exercise?.length || 0), 0) || 0;
|
||||
|
||||
// Get student analytics
|
||||
const studentAnalytics = await Promise.all(
|
||||
members
|
||||
.filter((member) => member.role_id === 3)
|
||||
.map((member) => getStudentOverview(courseId, member))
|
||||
);
|
||||
|
||||
analytics.students = studentAnalytics.filter(
|
||||
(student): student is StudentOverview => student !== null
|
||||
);
|
||||
|
||||
// Calculate aggregated metrics
|
||||
if (analytics.students.length > 0) {
|
||||
analytics.lessonCompletionRate = Math.round(
|
||||
analytics.students.reduce((sum, student) => sum + student.progressPercentage, 0) /
|
||||
analytics.students.length
|
||||
);
|
||||
|
||||
analytics.exerciseCompletionRate = Math.round(
|
||||
analytics.students.reduce((sum, student) => {
|
||||
const completionRate =
|
||||
student.totalExercises > 0
|
||||
? (student.exercisesSubmitted / student.totalExercises) * 100
|
||||
: 0;
|
||||
return sum + completionRate;
|
||||
}, 0) / analytics.students.length
|
||||
);
|
||||
|
||||
analytics.averageGrade = Math.round(
|
||||
analytics.students.reduce((sum, student) => sum + student.averageGrade, 0) /
|
||||
analytics.students.length
|
||||
);
|
||||
}
|
||||
|
||||
return analytics;
|
||||
}
|
||||
|
||||
async function getLastLogin(userId: string): Promise<string | undefined> {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('analytics_login_events')
|
||||
.select('logged_in_at')
|
||||
.eq('user_id', userId)
|
||||
.order('logged_in_at', { ascending: false })
|
||||
.limit(1);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return data?.[0]?.logged_in_at;
|
||||
} catch (error) {
|
||||
console.error('Error fetching last login:', error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function getStudentOverview(courseId: string, member: any): Promise<StudentOverview | null> {
|
||||
try {
|
||||
// Use the same service function that the working analytics/user API uses
|
||||
const { data: courseProgressData, error: progressError } = await fetchProfileCourseProgress(
|
||||
courseId,
|
||||
member.profile_id
|
||||
);
|
||||
|
||||
if (progressError) {
|
||||
console.error('Error fetching course progress:', progressError);
|
||||
return null;
|
||||
}
|
||||
|
||||
const courseProgress = courseProgressData?.[0];
|
||||
if (!courseProgress) {
|
||||
console.error('No course progress data found');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fetch exercise stats using the same method as the people page
|
||||
const userExercisesStats = await fetchUserExercisesStats(courseId, member.profile_id);
|
||||
|
||||
// Calculate exercise completion using the same logic as people page
|
||||
const completedExercises =
|
||||
userExercisesStats?.filter((exercise) => exercise.isCompleted)?.length || 0;
|
||||
const totalExercises = userExercisesStats?.length || 0;
|
||||
|
||||
// Get last login using the same approach as analytics/user API
|
||||
const lastLoginDate = await getLastLogin(member.profile_id);
|
||||
|
||||
// Format last seen
|
||||
let lastSeen = 'Never';
|
||||
if (lastLoginDate) {
|
||||
const lastLogin = new Date(lastLoginDate);
|
||||
const now = new Date();
|
||||
const diffInHours = Math.floor((now.getTime() - lastLogin.getTime()) / (1000 * 60 * 60));
|
||||
|
||||
if (diffInHours < 1) {
|
||||
lastSeen = 'Just now';
|
||||
} else if (diffInHours < 24) {
|
||||
lastSeen = `${diffInHours} hours ago`;
|
||||
} else {
|
||||
const diffInDays = Math.floor(diffInHours / 24);
|
||||
lastSeen = `${diffInDays} days ago`;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate progress percentage
|
||||
const lessonsCompleted = courseProgress.lessons_completed || 0;
|
||||
const totalLessons = courseProgress.lessons_count || 0;
|
||||
const progressPercentage =
|
||||
totalLessons > 0 ? Math.round((lessonsCompleted / totalLessons) * 100) : 0;
|
||||
|
||||
// Calculate average grade using the same logic as the people page
|
||||
const totalEarnedPoints =
|
||||
userExercisesStats?.reduce((sum, exercise) => sum + exercise.score, 0) || 0;
|
||||
const totalPoints =
|
||||
userExercisesStats?.reduce((sum, exercise) => sum + exercise.totalPoints, 0) || 0;
|
||||
const averageGrade = totalPoints > 0 ? Math.round((totalEarnedPoints / totalPoints) * 100) : 0;
|
||||
|
||||
return {
|
||||
id: member.profile_id,
|
||||
profile: {
|
||||
fullname: member.profile?.fullname || 'Unknown',
|
||||
email: member.profile?.email || '',
|
||||
avatar_url: member.profile?.avatar_url || ''
|
||||
},
|
||||
lessonsCompleted,
|
||||
totalLessons,
|
||||
exercisesSubmitted: completedExercises,
|
||||
totalExercises: totalExercises,
|
||||
averageGrade,
|
||||
lastSeen,
|
||||
progressPercentage
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error getting student overview:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUserExercisesStats(
|
||||
courseId: string,
|
||||
userId: string
|
||||
): Promise<UserExercisesStats[] | undefined> {
|
||||
try {
|
||||
const { data: courseData, error: queryError } = await supabase
|
||||
.from('course')
|
||||
.select(
|
||||
`
|
||||
lesson!inner (
|
||||
title,exercise!inner (
|
||||
id,title,lesson_id,created_at,question (points),
|
||||
submission!left (
|
||||
id,total,status_id,submitted_by,groupmember!inner (id,profile_id)
|
||||
)
|
||||
)
|
||||
)
|
||||
`
|
||||
)
|
||||
.eq('lesson.exercise.submission.groupmember.profile_id', userId)
|
||||
.eq('id', courseId);
|
||||
|
||||
if (queryError) {
|
||||
console.error('Error fetching exercise data:', queryError);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!courseData || courseData.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userExercisesStats = courseData[0].lesson.flatMap(
|
||||
(lesson) =>
|
||||
lesson.exercise?.map((exercise) => {
|
||||
const totalPoints = exercise.question?.reduce((sum, q) => sum + (q.points || 0), 0) || 0;
|
||||
const userSubmission = exercise.submission?.[0];
|
||||
|
||||
return {
|
||||
id: exercise.id,
|
||||
lessonId: exercise.lesson_id,
|
||||
lessonTitle: lesson.title,
|
||||
title: exercise.title,
|
||||
status: userSubmission?.status_id,
|
||||
score: userSubmission?.total || 0,
|
||||
totalPoints,
|
||||
isCompleted: !!userSubmission
|
||||
};
|
||||
}) || []
|
||||
);
|
||||
|
||||
return userExercisesStats;
|
||||
} catch (error) {
|
||||
console.error('Unexpected error:', error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import CourseContainer from '$lib/components/CourseContainer/index.svelte';
|
||||
import { PageBody, PageNav } from '$lib/components/Page';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { getAccessToken } from '$lib/utils/functions/supabase';
|
||||
import { snackbar } from '$lib/components/Snackbar/store';
|
||||
import type { CourseAnalytics } from '$lib/utils/types/analytics';
|
||||
import { fade } from 'svelte/transition';
|
||||
import StudentTable from '$lib/components/Course/components/Analytics/StudentTable.svelte';
|
||||
import AnalyticsGraph from '$lib/components/Course/components/Analytics/AnalyticsGraph.svelte';
|
||||
import AnalyticsSkeleton from '$lib/components/Course/components/Analytics/AnalyticsSkeleton.svelte';
|
||||
import AnalyticsCard from '$lib/components/Course/components/Analytics/AnalyticsCard.svelte';
|
||||
import { ChartBar } from 'carbon-icons-svelte';
|
||||
import StudentsIcon from '$lib/components/Course/components/Analytics/icons/StudentsIcon.svelte';
|
||||
import LessonsIcon from '$lib/components/Course/components/Analytics/icons/LessonsIcon.svelte';
|
||||
import ProgressIcon from '$lib/components/Course/components/Analytics/icons/ProgressIcon.svelte';
|
||||
import ExercisesIcon from '$lib/components/Course/components/Analytics/icons/ExercisesIcon.svelte';
|
||||
import EmptyState from '$lib/components/Course/components/Analytics/EmptyState.svelte';
|
||||
|
||||
export let data;
|
||||
|
||||
let courseAnalytics: CourseAnalytics | null = null;
|
||||
let isLoading = true;
|
||||
|
||||
async function fetchCourseAnalytics() {
|
||||
try {
|
||||
isLoading = true;
|
||||
|
||||
const accessToken = await getAccessToken();
|
||||
const response = await fetch('/api/analytics/course', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: accessToken
|
||||
},
|
||||
body: JSON.stringify({ courseId: data.courseId })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
courseAnalytics = (await response.json()) as CourseAnalytics;
|
||||
} else {
|
||||
console.error('Failed to fetch course analytics:', response);
|
||||
snackbar.error('Failed to load analytics data');
|
||||
courseAnalytics = null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch course analytics:', error);
|
||||
snackbar.error('Failed to load analytics data');
|
||||
courseAnalytics = null;
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
fetchCourseAnalytics();
|
||||
});
|
||||
</script>
|
||||
|
||||
<CourseContainer bind:courseId={data.courseId}>
|
||||
<PageNav title={$t('course.navItem.analytics.title')} />
|
||||
<PageBody width="w-full max-w-6xl md:w-11/12">
|
||||
{#if isLoading}
|
||||
<AnalyticsSkeleton />
|
||||
{:else if courseAnalytics}
|
||||
<div class="min-h-screen" in:fade>
|
||||
<!-- Main Content Area -->
|
||||
<div class="px-4 py-8">
|
||||
<!-- Course Overview Cards -->
|
||||
<div class="mb-12">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<!-- Students Card -->
|
||||
<AnalyticsCard
|
||||
title={$t('analytics.students')}
|
||||
value={courseAnalytics?.totalStudents || 0}
|
||||
subtitle="{$t('analytics.tutors')}: {courseAnalytics?.totalTutors || 0}"
|
||||
icon={StudentsIcon}
|
||||
iconColor="blue"
|
||||
/>
|
||||
|
||||
<!-- Lessons Card -->
|
||||
<AnalyticsCard
|
||||
title={$t('analytics.lessons')}
|
||||
value={courseAnalytics?.totalLessons || 0}
|
||||
subtitle="{$t('analytics.exercises')}: {courseAnalytics?.totalExercises || 0}"
|
||||
icon={LessonsIcon}
|
||||
iconColor="green"
|
||||
/>
|
||||
|
||||
<!-- Progress Card -->
|
||||
<AnalyticsCard
|
||||
title={$t('analytics.progress')}
|
||||
value="{courseAnalytics?.lessonCompletionRate || 0}%"
|
||||
subtitle="{$t('analytics.avg_grade')}: {courseAnalytics?.averageGrade || 0}%"
|
||||
icon={ProgressIcon}
|
||||
iconColor="purple"
|
||||
/>
|
||||
|
||||
<!-- Exercise Completion Card -->
|
||||
<AnalyticsCard
|
||||
title={$t('analytics.exercises')}
|
||||
value="{courseAnalytics?.exerciseCompletionRate || 0}%"
|
||||
subtitle={$t('analytics.completion_rate')}
|
||||
icon={ExercisesIcon}
|
||||
iconColor="orange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts Section -->
|
||||
<div class="mb-12">
|
||||
<AnalyticsGraph {courseAnalytics} />
|
||||
</div>
|
||||
|
||||
<!-- Students Overview Table -->
|
||||
<div
|
||||
class="rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-neutral-800"
|
||||
>
|
||||
<div class="border-b border-gray-200 px-6 py-4 dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{$t('analytics.student_progress_overview')}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<StudentTable students={courseAnalytics?.students || []} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<EmptyState
|
||||
title={$t('analytics.no_analytics_data')}
|
||||
description={$t('analytics.no_analytics_description')}
|
||||
icon={ChartBar}
|
||||
className="h-screen"
|
||||
/>
|
||||
{/if}
|
||||
</PageBody>
|
||||
</CourseContainer>
|
||||
@@ -0,0 +1,7 @@
|
||||
export const load = ({ params }) => {
|
||||
const { id } = params;
|
||||
|
||||
return {
|
||||
courseId: id
|
||||
};
|
||||
};
|
||||
@@ -11,10 +11,21 @@
|
||||
|
||||
export let data;
|
||||
|
||||
// Get back URL from query parameters
|
||||
$: backUrl = $page.url.searchParams.get('back');
|
||||
|
||||
const handleClick = () => {
|
||||
const url = $page.url.href + '?add=true';
|
||||
goto(url);
|
||||
};
|
||||
|
||||
const handleBackNavigation = () => {
|
||||
if (backUrl) {
|
||||
goto(backUrl);
|
||||
} else {
|
||||
goto(`/courses/${data.courseId}/people`);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<CourseContainer bind:courseId={data.courseId}>
|
||||
@@ -22,12 +33,7 @@
|
||||
<slot:fragment slot="image">
|
||||
{#if data.personId}
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
<IconButton
|
||||
size="large"
|
||||
onClick={() => {
|
||||
goto(`/courses/${data.courseId}/people`);
|
||||
}}
|
||||
>
|
||||
<IconButton size="large" onClick={handleBackNavigation}>
|
||||
<ArrowLeft size={16} class="carbon-icon dark:text-white " />
|
||||
</IconButton>
|
||||
</RoleBasedSecurity>
|
||||
|
||||
@@ -105,7 +105,7 @@
|
||||
</div>
|
||||
</Grid>
|
||||
|
||||
<div class="mt-5 rounded-md border p-3 dark:border-neutral-600 md:p-5">
|
||||
<div class="mt-5 rounded-md border p-3 md:p-5 dark:border-neutral-600">
|
||||
<h3 class="text-2xl font-bold">
|
||||
{$t('analytics.exercises')}
|
||||
</h3>
|
||||
|
||||
Reference in New Issue
Block a user