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.">
@@ -861,6 +861,9 @@
"cancel": "Stornieren",
"post": "Post"
}
},
"analytics": {
"title": "Analyse"
}
},
"navItems": {
@@ -872,7 +875,8 @@
"nav_certificates": "Zertifikate",
"nav_landing_page": "Landingpage",
"nav_settings": "Einstellungen",
"nav_content": "Inhalt"
"nav_content": "Inhalt",
"nav_analytics": "Analyse"
},
"not_permitted": {
"header": "Keine Erlaubnis",
@@ -1552,11 +1556,27 @@
"assignment_completion": "Abschluss der Aufgabe",
"assignment_completion_description": "Prozentsatz der abgeschlossenen Aufgaben",
"average_grade": "Durchschnittsnote",
"average_grade_description": "Durchschnittsnote aller abgeschlossenen Aufgaben"
"average_grade_description": "Durchschnittsnote aller abgeschlossenen Aufgaben",
"students": "Studenten",
"lessons": "Unterricht",
"tutors": "Tutoren",
"avg_grade": "AVG -Note",
"completion_rate": "Abschlussrate",
"student_progress_overview": "Übersicht über den Fortschritt der Schüler",
"student_name": "Schülername",
"lessons_completed": "Lektionen abgeschlossen",
"exercises_submitted": "Übungen eingereicht",
"actions": "Aktionen",
"view_details": "Details anzeigen",
"no_students_found": "Keine Schüler gefunden",
"no_students_description": "In diesem Kurs sind noch keine Studenten eingeschrieben. Die Schüler werden hier erscheinen, sobald sie sich dem Kurs anschließen.",
"showing_students": "Zeigen {Start} bis {End} von {Total} -Studenten",
"no_analytics_data": "Keine Analysedaten",
"no_analytics_description": "Analysedaten für diesen Kurs können nicht geladen werden. Bitte versuchen Sie es später erneut oder wenden Sie sich an Support, wenn das Problem bestehen bleibt."
},
"common": {
"unsaved_changes": {
"message": "Sie haben nicht gerettete Änderungen. Bist du sicher, dass du gehen willst?"
}
}
}
}
@@ -535,6 +535,7 @@
"nav_submissions": "Submissions",
"nav_marks": "Marks",
"nav_people": "People",
"nav_analytics": "Analytics",
"nav_certificates": "Certificates",
"nav_landing_page": "Landing Page",
"nav_settings": "Settings"
@@ -1147,6 +1148,10 @@
"courses": "courses"
},
"analytics": {
"title": "Analytics"
},
"settings": {
"heading": "Settings",
"cover_image": "Cover Image",
@@ -1257,8 +1262,24 @@
"overall_course_progress_user_description": "Based on lesson completion",
"assignment_completion": "Assignment Completion",
"assignment_completion_description": "Percentage of completed assignments",
"average_grade_description": "Average grade of all completed assignments",
"students": "Students",
"lessons": "Lessons",
"tutors": "tutors",
"avg_grade": "Avg grade",
"completion_rate": "Completion rate",
"student_progress_overview": "Student Progress Overview",
"student_name": "Student Name",
"lessons_completed": "Lessons Completed",
"exercises_submitted": "Exercises Submitted",
"average_grade": "Average Grade",
"average_grade_description": "Average grade of all completed assignments"
"actions": "Actions",
"view_details": "View Details",
"no_students_found": "No Students Found",
"no_students_description": "There are no students enrolled in this course yet. Students will appear here once they join the course.",
"showing_students": "Showing {start} to {end} of {total} students",
"no_analytics_data": "No Analytics Data",
"no_analytics_description": "Unable to load analytics data for this course. Please try again later or contact support if the issue persists."
},
"upgrade": {
@@ -861,6 +861,9 @@
"cancel": "Cancelar",
"post": "Correo"
}
},
"analytics": {
"title": "Analítica"
}
},
"navItems": {
@@ -872,7 +875,8 @@
"nav_certificates": "Certificados",
"nav_landing_page": "Página de destino",
"nav_settings": "Ajustes",
"nav_content": "Contenido"
"nav_content": "Contenido",
"nav_analytics": "Analítica"
},
"not_permitted": {
"header": "Sin autorización",
@@ -1552,11 +1556,27 @@
"assignment_completion": "Finalización de la tarea",
"assignment_completion_description": "Porcentaje de tareas completadas",
"average_grade": "Calificación promedio",
"average_grade_description": "Calificación promedio de todas las tareas completadas"
"average_grade_description": "Calificación promedio de todas las tareas completadas",
"students": "Estudiantes",
"lessons": "Lecciones",
"tutors": "tutores",
"avg_grade": "AVG Grado",
"completion_rate": "Tasa de finalización",
"student_progress_overview": "Descripción general del progreso del estudiante",
"student_name": "Nombre de estudiante",
"lessons_completed": "Lecciones completadas",
"exercises_submitted": "Ejercicios presentados",
"actions": "Comportamiento",
"view_details": "Ver detalles",
"no_students_found": "No se encontraron estudiantes",
"no_students_description": "Todavía no hay estudiantes matriculados en este curso. Los estudiantes aparecerán aquí una vez que se unan al curso.",
"showing_students": "Mostrando {inicio} a {final} de {Total} Estudiantes",
"no_analytics_data": "Sin datos de análisis",
"no_analytics_description": "No se puede cargar datos de análisis para este curso. Vuelva a intentarlo más tarde o comuníquese con el soporte si el problema persiste."
},
"common": {
"unsaved_changes": {
"message": "Tienes cambios no salvos. ¿Estás seguro de que quieres irte?"
}
}
}
}
@@ -861,6 +861,9 @@
"cancel": "Annuler",
"post": "Poste"
}
},
"analytics": {
"title": "Analytique"
}
},
"navItems": {
@@ -872,7 +875,8 @@
"nav_certificates": "Certificats",
"nav_landing_page": "Page de destination",
"nav_settings": "Paramètres",
"nav_content": "Contenu"
"nav_content": "Contenu",
"nav_analytics": "Analytique"
},
"not_permitted": {
"header": "Aucune autorisation",
@@ -1552,7 +1556,23 @@
"assignment_completion": "Achèvement de la mission",
"assignment_completion_description": "Pourcentage de missions terminées",
"average_grade": "Note moyenne",
"average_grade_description": "Note moyenne de tous les devoirs terminés"
"average_grade_description": "Note moyenne de tous les devoirs terminés",
"students": "Étudiants",
"lessons": "Leçons",
"tutors": "tuteurs",
"avg_grade": "Grade AVG",
"completion_rate": "Taux d'achèvement",
"student_progress_overview": "Aperçu des progrès des étudiants",
"student_name": "Nom de l'étudiant",
"lessons_completed": "Leçons terminées",
"exercises_submitted": "Exercices soumis",
"actions": "Actes",
"view_details": "Afficher les détails",
"no_students_found": "Aucun étudiant trouvé",
"no_students_description": "Il n'y a pas encore d'étudiants inscrits à ce cours. Les étudiants apparaîtront ici une fois qu'ils auront participé au cours.",
"showing_students": "Affichage {démarrer} à {fin} des étudiants {totaux}",
"no_analytics_data": "Aucune donnée d'analyse",
"no_analytics_description": "Impossible de charger des données d'analyse pour ce cours. Veuillez réessayer plus tard ou contacter le support si le problème persiste."
},
"common": {
"unsaved_changes": {
@@ -861,6 +861,9 @@
"cancel": "रद्द करना",
"post": "डाक"
}
},
"analytics": {
"title": "एनालिटिक्स"
}
},
"navItems": {
@@ -872,7 +875,8 @@
"nav_certificates": "प्रमाण पत्र",
"nav_landing_page": "लैंडिंग पृष्ठ",
"nav_settings": "समायोजन",
"nav_content": "सामग्री"
"nav_content": "सामग्री",
"nav_analytics": "एनालिटिक्स"
},
"not_permitted": {
"header": "अनुमति नहीं",
@@ -1552,11 +1556,27 @@
"assignment_completion": "असाइनमेंट पूरा करना",
"assignment_completion_description": "पूर्ण किये गये कार्यों का प्रतिशत",
"average_grade": "औसत क्षेणी",
"average_grade_description": "सभी पूर्ण किए गए असाइनमेंट का औसत ग्रेड"
"average_grade_description": "सभी पूर्ण किए गए असाइनमेंट का औसत ग्रेड",
"students": "छात्र",
"lessons": "पाठ",
"tutors": "ट्यूटर्स",
"avg_grade": "AVG ग्रेड",
"completion_rate": "पूर्णता दर",
"student_progress_overview": "छात्र प्रगति अवलोकन",
"student_name": "छात्र नाम",
"lessons_completed": "सबक पूरा हुआ",
"exercises_submitted": "प्रस्तुत अभ्यास",
"actions": "कार्रवाई",
"view_details": "विवरण देखें",
"no_students_found": "कोई छात्र नहीं मिला",
"no_students_description": "इस पाठ्यक्रम में अभी तक कोई छात्र नामांकित नहीं हैं। पाठ्यक्रम में शामिल होने के बाद छात्र यहां दिखाई देंगे।",
"showing_students": "{TOTAL} {TOTAL} छात्रों के {अंत} को दिखा रहा है",
"no_analytics_data": "कोई एनालिटिक्स डेटा नहीं",
"no_analytics_description": "इस पाठ्यक्रम के लिए एनालिटिक्स डेटा लोड करने में असमर्थ। यदि समस्या बनी रहती है, तो कृपया बाद में पुनः प्रयास करें या समर्थन से संपर्क करें।"
},
"common": {
"unsaved_changes": {
"message": "आपके पास अनसुना परिवर्तन हैं। क्या आपकी वाकई छोड़ने की इच्छा है?"
}
}
}
}
@@ -331,7 +331,23 @@
"assignment_completion": "Zakończenie przypisania",
"assignment_completion_description": "Procent wypełnionych zadań",
"average_grade": "Średnia ocena",
"average_grade_description": "Średnia ocena wszystkich zakończonych zadań"
"average_grade_description": "Średnia ocena wszystkich zakończonych zadań",
"students": "Studenci",
"lessons": "Lekcje",
"tutors": "Tutorzy",
"avg_grade": "Klasa AVG",
"completion_rate": "Wskaźnik zakończenia",
"student_progress_overview": "Przegląd postępu uczniów",
"student_name": "Nazwa ucznia",
"lessons_completed": "Ukończone lekcje",
"exercises_submitted": "Przedstawione ćwiczenia",
"actions": "Działania",
"view_details": "Zobacz szczegóły",
"no_students_found": "Nie znaleziono uczniów",
"no_students_description": "Na ten kurs nie zapisano jeszcze studentów. Studenci pojawią się tutaj, gdy dołączą do kursu.",
"showing_students": "Pokazuje {start} do {end} studentów {total}",
"no_analytics_data": "Brak danych analitycznych",
"no_analytics_description": "Nie można załadować danych analitycznych dla tego kursu. Spróbuj ponownie później lub skontaktuj się z wsparciem, jeśli problem będzie się utrzymywał."
},
"audience": {
"title": "Publiczność",
@@ -1000,6 +1016,9 @@
"cancel": "Anulować",
"post": "Post"
}
},
"analytics": {
"title": "Analityka"
}
},
"navItems": {
@@ -1011,7 +1030,8 @@
"nav_people": "Ludzie",
"nav_certificates": "Certyfikaty",
"nav_landing_page": "Strona docelowa",
"nav_settings": "Ustawienia"
"nav_settings": "Ustawienia",
"nav_analytics": "Analityka"
}
},
"courses": {
@@ -1523,5 +1543,10 @@
"resend_in": "Ponownie weryfikacja w",
"seconds": "towary drugiej jakości",
"snackbar_error": "Błąd podczas wysyłania kodu"
},
"common": {
"unsaved_changes": {
"message": "Masz niezbawione zmiany. Czy na pewno chcesz odejść?"
}
}
}
}
@@ -861,6 +861,9 @@
"cancel": "Cancelar",
"post": "Publicar"
}
},
"analytics": {
"title": "Análise"
}
},
"navItems": {
@@ -872,7 +875,8 @@
"nav_certificates": "Certificados",
"nav_landing_page": "Página inicial",
"nav_settings": "Configurações",
"nav_content": "Contente"
"nav_content": "Contente",
"nav_analytics": "Análise"
},
"not_permitted": {
"header": "Sem permissão",
@@ -1552,11 +1556,27 @@
"assignment_completion": "Conclusão da tarefa",
"assignment_completion_description": "Porcentagem de tarefas concluídas",
"average_grade": "Nota Média",
"average_grade_description": "Nota média de todas as tarefas concluídas"
"average_grade_description": "Nota média de todas as tarefas concluídas",
"students": "Alunos",
"lessons": "Lições",
"tutors": "tutores",
"avg_grade": "Grau AVG",
"completion_rate": "Taxa de conclusão",
"student_progress_overview": "Visão geral do progresso do aluno",
"student_name": "Nome do aluno",
"lessons_completed": "Lições concluídas",
"exercises_submitted": "Exercícios enviados",
"actions": "Ações",
"view_details": "Ver detalhes",
"no_students_found": "Nenhum aluno encontrado",
"no_students_description": "Ainda não há alunos matriculados neste curso. Os alunos aparecerão aqui quando ingressarem no curso.",
"showing_students": "Mostrando {start} para {end} de {total} alunos",
"no_analytics_data": "Sem dados de análise",
"no_analytics_description": "Não é possível carregar dados de análise para este curso. Tente novamente mais tarde ou entre em contato com o suporte se o problema persistir."
},
"common": {
"unsaved_changes": {
"message": "Você tem mudanças não salvas. Tem certeza de que deseja sair?"
}
}
}
}
@@ -861,6 +861,9 @@
"cancel": "Отмена",
"post": "Почта"
}
},
"analytics": {
"title": "Аналитика"
}
},
"navItems": {
@@ -872,7 +875,8 @@
"nav_certificates": "Сертификаты",
"nav_landing_page": "Целевая страница",
"nav_settings": "Настройки",
"nav_content": "Содержание"
"nav_content": "Содержание",
"nav_analytics": "Аналитика"
},
"not_permitted": {
"header": "Нет разрешения",
@@ -1552,7 +1556,23 @@
"assignment_completion": "Выполнение задания",
"assignment_completion_description": "Процент выполненных заданий",
"average_grade": "Средняя оценка",
"average_grade_description": "Средняя оценка за все выполненные задания"
"average_grade_description": "Средняя оценка за все выполненные задания",
"students": "Студенты",
"lessons": "Уроки",
"tutors": "репетиторы",
"avg_grade": "AVG Grade",
"completion_rate": "Коэффициент завершения",
"student_progress_overview": "Студенческий прогресс обзор",
"student_name": "Студенческое имя",
"lessons_completed": "Уроки завершены",
"exercises_submitted": "Упражнения представлены",
"actions": "Действия",
"view_details": "Просмотреть детали",
"no_students_found": "Никаких студентов не нашли",
"no_students_description": "В этом курсе еще нет студентов. Студенты появятся здесь, как только они присоединятся к курсу.",
"showing_students": "Показ {start} to {end} {otto} Студенты",
"no_analytics_data": "Нет аналитических данных",
"no_analytics_description": "Невозможно загрузить данные аналитики для этого курса. Пожалуйста, попробуйте еще раз или свяжитесь с поддержкой, если проблема сохранится."
},
"common": {
"unsaved_changes": {
@@ -861,6 +861,9 @@
"cancel": "Hủy bỏ",
"post": "Bưu kiện"
}
},
"analytics": {
"title": "Phân tích"
}
},
"navItems": {
@@ -872,7 +875,8 @@
"nav_certificates": "Chứng chỉ",
"nav_landing_page": "Trang đích",
"nav_settings": "Cài đặt",
"nav_content": "Nội dung"
"nav_content": "Nội dung",
"nav_analytics": "Phân tích"
},
"not_permitted": {
"header": "Không cho phép",
@@ -1552,11 +1556,27 @@
"assignment_completion": "Hoàn thành bài tập",
"assignment_completion_description": "Tỷ lệ bài tập đã hoàn thành",
"average_grade": "Điểm trung bình",
"average_grade_description": "Điểm trung bình của tất cả các bài tập đã hoàn thành"
"average_grade_description": "Điểm trung bình của tất cả các bài tập đã hoàn thành",
"students": "Sinh viên",
"lessons": "Bài học",
"tutors": "gia sư",
"avg_grade": "Lớp AVG",
"completion_rate": "Tỷ lệ hoàn thành",
"student_progress_overview": "Tổng quan về tiến trình học sinh",
"student_name": "Tên sinh viên",
"lessons_completed": "Bài học hoàn thành",
"exercises_submitted": "Bài tập được gửi",
"actions": "Hành động",
"view_details": "Xem chi tiết",
"no_students_found": "Không có học sinh tìm thấy",
"no_students_description": "Không có sinh viên đăng ký vào khóa học này. Học sinh sẽ xuất hiện ở đây khi họ tham gia khóa học.",
"showing_students": "Hiển thị {start} đến {end} của {Total} sinh viên",
"no_analytics_data": "Không có dữ liệu phân tích",
"no_analytics_description": "Không thể tải dữ liệu phân tích cho khóa học này. Vui lòng thử lại sau hoặc liên hệ với hỗ trợ nếu vấn đề vẫn còn."
},
"common": {
"unsaved_changes": {
"message": "Bạn có những thay đổi chưa được lưu. Bạn có chắc bạn muốn rời đi?"
}
}
}
}
@@ -1,3 +1,64 @@
export interface CourseAnalytics {
totalTutors: number;
totalStudents: number;
totalLessons: number;
totalExercises: number;
lessonCompletionRate: number;
exerciseCompletionRate: number;
averageGrade: number;
students: StudentOverview[];
}
export interface StudentOverview {
id: string;
profile: {
fullname: string;
email: string;
avatar_url: string;
};
lessonsCompleted: number;
totalLessons: number;
exercisesSubmitted: number;
totalExercises: number;
averageGrade: number;
lastSeen: string;
progressPercentage: number;
}
export interface StudentDetailModal {
student: {
profile: {
id: string;
fullname: string;
email: string;
avatar_url: string;
};
overallStats: {
totalCourses: number;
totalLessonsCompleted: number;
totalExercisesSubmitted: number;
overallAverageGrade: number;
lastSeen: string;
};
courses: StudentCourseAnalytics[];
};
}
export interface StudentCourseAnalytics {
courseId: string;
courseTitle: string;
courseLogo: string;
role: 'student' | 'tutor' | 'admin';
lessonsCompleted: number;
totalLessons: number;
exercisesSubmitted: number;
totalExercises: number;
averageGrade: number;
progressPercentage: number;
lastActivity: string;
enrollmentDate: string;
}
interface User {
id: string;
fullName: string;
@@ -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>
@@ -0,0 +1,31 @@
set check_function_bodies = off;
CREATE OR REPLACE FUNCTION public.get_course_progress(course_id_arg uuid, profile_id_arg uuid)
RETURNS TABLE(lessons_count bigint, lessons_completed bigint, exercises_count bigint, exercises_completed bigint)
LANGUAGE plpgsql
AS $function$
BEGIN
RETURN QUERY
select
count(distinct lesson.id) as lessons_count,
count(distinct lesson_completion.id) as lessons_completed,
count(distinct exercise.id) as exercises_count,
count(distinct submission.id) as exercises_completed
from
course
join "group" on "group".id = course.group_id
join groupmember on groupmember.group_id = course.group_id
join profile on profile.id = groupmember.profile_id
left join lesson on lesson.course_id = course.id
left join lesson_completion on lesson_completion.lesson_id = lesson.id
and lesson_completion.is_complete = true
and lesson_completion.profile_id = profile.id
left join exercise on exercise.lesson_id = lesson.id
left join submission on submission.exercise_id = exercise.id
and submission.submitted_by = groupmember.id
where
course.id = course_id_arg
and profile.id = profile_id_arg;
END;
$function$
;