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 <best@classroomio.com>
This commit is contained in:
Nwosu Emmanuel
2025-09-18 01:00:49 +01:00
committed by GitHub
co-authored by Rotimi Best
parent f3de6a063e
commit ec3875bac2
28 changed files with 1322 additions and 40 deletions
@@ -0,0 +1,38 @@
<script lang="ts">
export let title: string;
export let value: string | number;
export let subtitle: string = '';
export let icon: any;
export let iconColor: string = 'blue';
export let className: string = '';
$: colorClasses = {
blue: 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400',
green: 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400',
purple: 'bg-purple-50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400',
orange: 'bg-orange-50 dark:bg-orange-900/20 text-orange-600 dark:text-orange-400',
red: 'bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400',
yellow: 'bg-yellow-50 dark:bg-yellow-900/20 text-yellow-600 dark:text-yellow-400'
};
</script>
<div
class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800 {className}"
>
<div class="flex items-center space-x-4">
<div class="flex h-12 w-12 items-center justify-center rounded-lg {colorClasses[iconColor]}">
<svelte:component this={icon} className={colorClasses[iconColor]} />
</div>
<div>
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">{title}</p>
<p class="text-2xl font-semibold text-gray-900 dark:text-white">
{value}
</p>
{#if subtitle}
<p class="text-xs capitalize text-gray-500 dark:text-gray-500">
{subtitle}
</p>
{/if}
</div>
</div>
</div>
@@ -0,0 +1,175 @@
<script lang="ts">
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import type { CourseAnalytics } from '$lib/utils/types/analytics';
import { ScaleTypes } from '@carbon/charts-svelte';
import '@carbon/charts-svelte/styles.css';
export let courseAnalytics: CourseAnalytics | null = null;
let BarChartSimple: any;
// Transform data for progress distribution chart
$: progressChartData = courseAnalytics?.students
? [
{
group: '80%+ Progress',
value: courseAnalytics.students.filter((s) => s.progressPercentage >= 80).length
},
{
group: '60-79% Progress',
value: courseAnalytics.students.filter(
(s) => s.progressPercentage >= 60 && s.progressPercentage < 80
).length
},
{
group: 'Below 60% Progress',
value: courseAnalytics.students.filter((s) => s.progressPercentage < 60).length
}
]
: [];
// Transform data for grade distribution chart
$: gradeChartData = courseAnalytics?.students
? [
{
group: '90%+ Grade',
value: courseAnalytics.students.filter((s) => s.averageGrade >= 90).length
},
{
group: '80-89% Grade',
value: courseAnalytics.students.filter((s) => s.averageGrade >= 80 && s.averageGrade < 90)
.length
},
{
group: '70-79% Grade',
value: courseAnalytics.students.filter((s) => s.averageGrade >= 70 && s.averageGrade < 80)
.length
},
{
group: 'Below 70% Grade',
value: courseAnalytics.students.filter((s) => s.averageGrade < 70).length
}
]
: [];
onMount(async () => {
if (browser) {
const charts = await import('@carbon/charts-svelte');
BarChartSimple = charts.BarChartSimple;
}
});
// Chart options for progress distribution
$: progressChartOptions = {
axes: {
left: {
mapsTo: 'value',
title: 'Number of Students'
},
bottom: {
mapsTo: 'group',
scaleType: ScaleTypes.LABELS,
title: 'Progress Range'
}
},
height: '300px',
data: {
loading: !courseAnalytics
},
color: {
scale: {
'80%+ Progress': '#10b981', // green
'60-79% Progress': '#f59e0b', // yellow
'Below 60% Progress': '#ef4444' // red
}
}
};
// Chart options for grade distribution
$: gradeChartOptions = {
axes: {
left: {
mapsTo: 'value',
title: 'Number of Students'
},
bottom: {
mapsTo: 'group',
scaleType: ScaleTypes.LABELS,
title: 'Grade Range'
}
},
height: '300px',
data: {
loading: !courseAnalytics
},
color: {
scale: {
'90%+ Grade': '#10b981', // green
'80-89% Grade': '#3b82f6', // blue
'70-79% Grade': '#f59e0b', // yellow
'Below 70% Grade': '#ef4444' // red
}
}
};
</script>
{#if browser && BarChartSimple}
<div class="grid grid-cols-1 gap-8 lg:grid-cols-2">
<!-- Progress Distribution Chart -->
<div
class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800"
>
<h3 class="mb-6 text-lg font-semibold text-gray-900 dark:text-white">
Student Progress Distribution
</h3>
{#if courseAnalytics?.students && courseAnalytics.students.length > 0}
<svelte:component
this={BarChartSimple}
data={progressChartData}
options={progressChartOptions}
/>
{:else}
<div class="flex h-[300px] items-center justify-center text-gray-500 dark:text-gray-400">
No progress data available
</div>
{/if}
</div>
<!-- Grade Distribution Chart -->
<div
class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800"
>
<h3 class="mb-6 text-lg font-semibold text-gray-900 dark:text-white">Grade Distribution</h3>
{#if courseAnalytics?.students && courseAnalytics.students.length > 0}
<svelte:component this={BarChartSimple} data={gradeChartData} options={gradeChartOptions} />
{:else}
<div class="flex h-[300px] items-center justify-center text-gray-500 dark:text-gray-400">
No grade data available
</div>
{/if}
</div>
</div>
{:else}
<!-- Loading state -->
<div class="grid grid-cols-1 gap-8 lg:grid-cols-2">
<div
class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800"
>
<h3 class="mb-6 text-lg font-semibold text-gray-900 dark:text-white">
Student Progress Distribution
</h3>
<div class="flex h-[300px] items-center justify-center">
<div class="animate-pulse text-gray-500 dark:text-gray-400">Loading chart...</div>
</div>
</div>
<div
class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800"
>
<h3 class="mb-6 text-lg font-semibold text-gray-900 dark:text-white">Grade Distribution</h3>
<div class="flex h-[300px] items-center justify-center">
<div class="animate-pulse text-gray-500 dark:text-gray-400">Loading chart...</div>
</div>
</div>
</div>
{/if}
@@ -0,0 +1,67 @@
<script lang="ts">
import { SkeletonPlaceholder } from 'carbon-components-svelte';
</script>
<div class="min-h-screen">
<div class="px-4 py-8">
<!-- Course Overview Cards Skeleton -->
<div class="mb-12">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
{#each Array(4) as _}
<div
class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800"
>
<div class="flex items-center space-x-4">
<SkeletonPlaceholder style="width: 48px; height: 48px;" />
<div class="flex-1">
<SkeletonPlaceholder style="width: 80px; height: 16px; margin-bottom: 8px;" />
<SkeletonPlaceholder style="width: 40px; height: 24px; margin-bottom: 4px;" />
<SkeletonPlaceholder style="width: 60px; height: 12px;" />
</div>
</div>
</div>
{/each}
</div>
</div>
<!-- Charts Section Skeleton -->
<div class="mb-12">
<div
class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800"
>
<SkeletonPlaceholder style="width: 200px; height: 24px; margin-bottom: 24px;" />
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<SkeletonPlaceholder style="width: 100%; height: 300px;" />
<SkeletonPlaceholder style="width: 100%; height: 300px;" />
</div>
</div>
</div>
<!-- Students Table Skeleton -->
<div
class="rounded-lg border border-gray-200 bg-white dark:border-neutral-700 dark:bg-neutral-800"
>
<div class="border-b border-gray-200 px-6 py-4 dark:border-neutral-700">
<SkeletonPlaceholder style="width: 200px; height: 24px;" />
</div>
<div class="p-6">
<div class="space-y-4">
{#each Array(5) as _}
<div class="flex items-center space-x-4">
<SkeletonPlaceholder style="width: 32px; height: 32px;" />
<div class="flex-1">
<SkeletonPlaceholder style="width: 150px; height: 16px; margin-bottom: 4px;" />
<SkeletonPlaceholder style="width: 200px; height: 12px;" />
</div>
<SkeletonPlaceholder style="width: 80px; height: 16px;" />
<SkeletonPlaceholder style="width: 60px; height: 16px;" />
<SkeletonPlaceholder style="width: 40px; height: 20px;" />
<SkeletonPlaceholder style="width: 80px; height: 12px;" />
<SkeletonPlaceholder style="width: 100px; height: 32px;" />
</div>
{/each}
</div>
</div>
</div>
</div>
</div>
@@ -0,0 +1,22 @@
<script lang="ts">
import { ChartBar } from 'carbon-icons-svelte';
export let title: string = 'No data available';
export let description: string = 'There is no data to display at this time.';
export let icon: any = ChartBar;
export let className: string = '';
</script>
<div class="flex flex-col items-center justify-center px-4 py-12 {className}">
<div
class="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-gray-100 dark:bg-neutral-800"
>
<svelte:component this={icon} size="24" class="text-gray-400 dark:text-gray-500" />
</div>
<h3 class="mb-2 text-lg font-medium text-gray-900 dark:text-white">
{title}
</h3>
<p class="max-w-sm text-center text-sm text-gray-500 dark:text-gray-400">
{description}
</p>
</div>
@@ -0,0 +1,183 @@
<script lang="ts">
import Avatar from '$lib/components/Avatar/index.svelte';
import { Tag, Pagination } from 'carbon-components-svelte';
import type { StudentOverview } from '$lib/utils/types/analytics';
import EmptyState from './EmptyState.svelte';
import { User } from 'carbon-icons-svelte';
import { course } from '../../store';
import { goto } from '$app/navigation';
import { t } from '$lib/utils/functions/translations';
export let students: StudentOverview[] = [];
// Pagination state
let currentPage = 1;
const pageSize = 15;
// Computed values
$: totalPages = Math.ceil(students.length / pageSize);
$: startIndex = (currentPage - 1) * pageSize;
$: endIndex = startIndex + pageSize;
$: paginatedStudents = students.slice(startIndex, endIndex);
$: startItem = startIndex + 1;
$: endItem = Math.min(endIndex, students.length);
const GotoFullProfile = (student: StudentOverview) => {
if (!student) return;
goto(`/courses/${$course.id}/people/${student.id}?back=/courses/${$course.id}/analytics`);
};
function handlePageChange(event: CustomEvent) {
currentPage = event.detail.page;
}
// Reset to first page when students array changes
$: if (students.length > 0 && currentPage > totalPages) {
currentPage = 1;
}
</script>
{#if students.length === 0}
<EmptyState
title={$t('analytics.no_students_found')}
description={$t('analytics.no_students_description')}
icon={User}
/>
{:else}
<!-- Responsive Table -->
<div class="relative h-[300px] overflow-hidden">
<div class="h-full overflow-auto">
<table class="w-full min-w-[800px]">
<!-- Fixed Header -->
<thead class="sticky top-0 z-10 bg-white dark:bg-neutral-800">
<tr class="border-b border-gray-200 dark:border-neutral-600">
<th
class="sticky left-0 top-0 z-20 min-w-[200px] whitespace-nowrap bg-white px-4 py-3 text-left text-sm font-medium text-gray-500 dark:bg-neutral-800 dark:text-gray-300"
>
{$t('analytics.student_name')}
</th>
<th
class="min-w-[150px] whitespace-nowrap px-4 py-3 text-left text-sm font-medium text-gray-500 dark:text-gray-300"
>
{$t('analytics.lessons_completed')}
</th>
<th
class="min-w-[140px] whitespace-nowrap px-4 py-3 text-left text-sm font-medium text-gray-500 dark:text-gray-300"
>
{$t('analytics.exercises_submitted')}
</th>
<th
class="min-w-[120px] whitespace-nowrap px-4 py-3 text-left text-sm font-medium text-gray-500 dark:text-gray-300"
>
{$t('analytics.average_grade')}
</th>
<th
class="min-w-[120px] whitespace-nowrap px-4 py-3 text-left text-sm font-medium text-gray-500 dark:text-gray-300"
>
{$t('analytics.last_seen')}
</th>
<th
class="min-w-[120px] whitespace-nowrap px-4 py-3 text-left text-sm font-medium text-gray-500 dark:text-gray-300"
>
{$t('analytics.actions')}
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-600">
{#each paginatedStudents as student}
<tr class="group h-[100px] border-b hover:bg-gray-50 dark:hover:bg-gray-700">
<!-- Fixed Name Column -->
<td
class="sticky left-0 z-10 min-w-[200px] whitespace-nowrap bg-white px-4 py-3 transition-colors group-hover:bg-gray-50 dark:bg-neutral-800 dark:group-hover:bg-gray-700"
>
<div class="flex items-center gap-3">
<Avatar
src={student.profile.avatar_url}
name={student.profile.fullname}
width="w-8"
height="h-8"
/>
<div class="min-w-0 flex-1">
<p class="truncate font-medium text-gray-900 dark:text-white">
{student.profile.fullname}
</p>
<p class="truncate text-sm text-gray-500 dark:text-gray-400">
{student.profile.email}
</p>
</div>
</div>
</td>
<!-- Scrollable Content -->
<td class="min-w-[150px] whitespace-nowrap px-4 py-3">
<div class="flex items-center gap-3">
<span class="text-sm font-medium text-gray-900 dark:text-white">
{student.lessonsCompleted}/{student.totalLessons}
</span>
<div class="w-20 flex-shrink-0">
<div class="h-2 rounded-full bg-gray-200 dark:bg-neutral-700">
<div
class="h-2 rounded-full bg-blue-500"
style="width: {student.progressPercentage}%"
></div>
</div>
</div>
</div>
</td>
<td class="min-w-[140px] whitespace-nowrap px-4 py-3">
<span class="text-sm font-medium text-gray-900 dark:text-white">
{student.exercisesSubmitted} out of {student.totalExercises}
</span>
</td>
<td class="min-w-[120px] whitespace-nowrap px-4 py-3">
<Tag
type={student.averageGrade >= 80
? 'green'
: student.averageGrade >= 60
? 'blue'
: 'red'}
size="sm"
>
{student.averageGrade}%
</Tag>
</td>
<td class="min-w-[120px] whitespace-nowrap px-4 py-3">
<span class="text-sm text-gray-500 dark:text-gray-400">
{student.lastSeen}
</span>
</td>
<td class="min-w-[120px] whitespace-nowrap px-4 py-3">
<button
class="whitespace-nowrap rounded bg-blue-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-blue-700"
on:click={() => GotoFullProfile(student)}
>
{$t('analytics.view_details')}
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
<!-- Pagination Controls -->
{#if students.length > pageSize}
<div
class="mt-4 flex items-center justify-between border-t border-gray-200 px-4 py-3 dark:border-neutral-600"
>
<div class="text-sm text-gray-700 dark:text-gray-300">
{$t('analytics.showing_students', {
start: startItem,
end: endItem,
total: students.length
})}
</div>
<Pagination
page={currentPage}
totalItems={students.length}
{pageSize}
on:change={handlePageChange}
/>
</div>
{/if}
{/if}
@@ -0,0 +1,18 @@
<script lang="ts">
export let className: string = '';
</script>
<svg
width="24"
height="24"
class="h-6 w-6 {className}"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
d="M3 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z"
clip-rule="evenodd"
/>
</svg>
@@ -0,0 +1,19 @@
<script lang="ts">
export let className: string = '';
</script>
<svg
width="24"
height="24"
class="h-6 w-6 {className}"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z" />
<path
fill-rule="evenodd"
d="M4 5a2 2 0 012-2h8a2 2 0 012 2v6a2 2 0 01-2 2H6a2 2 0 01-2-2V5zm3 1a1 1 0 000 2h.01a1 1 0 100-2H7zm3 0a1 1 0 000 2h3a1 1 0 100-2h-3zm-3 4a1 1 0 100 2h.01a1 1 0 100-2H7zm3 0a1 1 0 100 2h3a1 1 0 100-2h-3z"
clip-rule="evenodd"
/>
</svg>
@@ -0,0 +1,18 @@
<script lang="ts">
export let className: string = '';
</script>
<svg
width="24"
height="24"
class="h-6 w-6 {className}"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
d="M6.267 3.455a3.066 3.066 0 001.745-.723 3.066 3.066 0 013.976 0 3.066 3.066 0 001.745.723 3.066 3.066 0 012.812 2.812c.051.643.304 1.254.723 1.745a3.066 3.066 0 010 3.976 3.066 3.066 0 00-.723 1.745 3.066 3.066 0 01-2.812 2.812 3.066 3.066 0 00-1.745.723 3.066 3.066 0 01-3.976 0 3.066 3.066 0 00-1.745-.723 3.066 3.066 0 01-2.812-2.812 3.066 3.066 0 00-.723-1.745 3.066 3.066 0 010-3.976 3.066 3.066 0 00.723-1.745 3.066 3.066 0 012.812-2.812zm7.44 5.252a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd"
/>
</svg>
@@ -0,0 +1,16 @@
<script lang="ts">
export let className: string = '';
</script>
<svg
width="24"
height="24"
class="h-6 w-6 {className}"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M13 6a3 3 0 11-6 0 3 3 0 016 0zM18 8a2 2 0 11-4 0 2 2 0 014 0zM14 15a4 4 0 00-8 0v3h8v-3z"
/>
</svg>
@@ -9,6 +9,7 @@
import IbmWatsonKnowledgeCatalog from 'carbon-icons-svelte/lib/IbmWatsonKnowledgeCatalog.svelte';
import Settings from 'carbon-icons-svelte/lib/Settings.svelte';
import Bullhorn from 'carbon-icons-svelte/lib/Bullhorn.svelte';
import Analytics from 'carbon-icons-svelte/lib/Analytics.svelte';
import { NAV_IDS } from './constants';
export let name = '';
@@ -28,6 +29,8 @@
<Result size={20} class="carbon-icon mr-2" />
{:else if name === NAV_IDS.PEOPLE}
<Group size={20} class="carbon-icon mr-2" />
{:else if name === NAV_IDS.ANALYTICS}
<Analytics size={20} class="carbon-icon mr-2" />
{:else if name === NAV_IDS.LANDING_PAGE}
<ApplicationWeb size={20} class="carbon-icon mr-2" />
{:else if name === NAV_IDS.CERTIFICATES}
@@ -7,6 +7,7 @@ export const NAV_IDS = {
CERTIFICATES: 'CERTIFICATES',
LANDING_PAGE: 'LANDING_PAGE',
PEOPLE: 'PEOPLE',
ANALYTICS: 'ANALYTICS',
SETTINGS: 'SETTINGS',
SECTION: 'SECTION'
};
@@ -158,6 +158,16 @@
return isStudent ? $currentOrg.customization.course.newsfeed : true;
}
},
{
id: NAV_IDS.ANALYTICS,
label: $t('course.navItems.nav_analytics'),
to: getNavItemRoute($course.id, 'analytics'),
isPaidFeature: false,
hideSortIcon: true,
show() {
return !isStudent;
}
},
{
id: NAV_IDS.LESSONS,
label: $t('course.navItems.nav_content'),
@@ -262,14 +272,14 @@
class={`
${
$sideBar.hidden
? '-translate-x-[100%] absolute z-[40]'
: 'translate-x-0 absolute md:relative z-[40]'
? 'absolute z-[40] -translate-x-[100%]'
: 'absolute z-[40] translate-x-0 md:relative'
}
transition w-[90vw] md:w-[300px] lg:w-[350px] bg-gray-100 dark:bg-black h-[calc(100vh-48px)]
h-[calc(100vh-48px)] w-[90vw] bg-gray-100 transition md:w-[300px] lg:w-[350px] dark:bg-black
${
resize ? 'border-r-8 border-r-blue-500' : 'dark:border-r-neutral-600'
} overflow-y-auto border border-l-0 border-t-0 border-b-0 border-r-1`}
} border-r-1 overflow-y-auto border border-b-0 border-l-0 border-t-0`}
style={$sideBar.hidden === true ? 'width:0' : 'width:300px'}
bind:this={sidebarRef}
>
@@ -297,7 +307,7 @@
{#if $course.version === COURSE_VERSION.V1}
{#each $lessons as item, index}
<a
class="pl-7 w-[95%] text-[0.80rem] mb-2 text-black dark:text-white {isStudent &&
class="mb-2 w-[95%] pl-7 text-[0.80rem] text-black dark:text-white {isStudent &&
!item.is_unlocked
? 'cursor-not-allowed'
: ''}"
@@ -309,7 +319,7 @@
title={item.title}
>
<div
class="flex items-center py-2 px-4 {NavClasses.item} {(
class="flex items-center px-4 py-2 {NavClasses.item} {(
path || $page.url.pathname
).includes(item.id) && NavClasses.active}"
>
@@ -319,7 +329,7 @@
size="sm"
shape="rounded-full"
/>
<span class="w-[85%] text-ellipsis line-clamp-2">{item.title}</span>
<span class="line-clamp-2 w-[85%] text-ellipsis">{item.title}</span>
<span class="grow" />
{#if !item.is_unlocked}
<span class="text-md ml-2" title="This lesson is locked.">
@@ -348,7 +358,7 @@
>
{#each section.lessons as item}
<a
class="pl-7 w-[95%] text-[0.80rem] mb-2 text-black dark:text-white {isStudent &&
class="mb-2 w-[95%] pl-7 text-[0.80rem] text-black dark:text-white {isStudent &&
!item.is_unlocked
? 'cursor-not-allowed'
: ''}"
@@ -360,11 +370,11 @@
title={item.title}
>
<div
class="flex items-center py-2 px-4 {NavClasses.item} {(
class="flex items-center px-4 py-2 {NavClasses.item} {(
path || $page.url.pathname
).includes(item.id) && NavClasses.active}"
>
<span class="w-[85%] text-ellipsis line-clamp-2">{item.title}</span>
<span class="line-clamp-2 w-[85%] text-ellipsis">{item.title}</span>
<span class="grow" />
{#if !item.is_unlocked}
<span class="text-md ml-2" title="This lesson is locked.">