feat: update analytics components and add automation translations

This commit is contained in:
Rotimi Best
2026-03-13 09:53:05 +01:00
parent c532b2e33f
commit fe75771d25
22 changed files with 1096 additions and 202 deletions
@@ -15,7 +15,19 @@
let { courseAnalytics = null }: Props = $props();
const chartConfig: Chart.ChartConfig = {};
const progressChartConfig = $derived({
students: {
label: $t('analytics.number_of_students'),
color: 'var(--chart-1)'
}
} satisfies Chart.ChartConfig);
const gradeChartConfig = $derived({
students: {
label: $t('analytics.number_of_students'),
color: 'var(--chart-2)'
}
} satisfies Chart.ChartConfig);
let hasStudentData = $derived(Boolean(courseAnalytics?.students?.length));
@@ -89,20 +101,28 @@
}
});
let progressChartColors = $derived({
domain: progressChartData.map((item) => item.group),
range: progressChartData.map((item) => item.color)
});
let progressSeries = $derived([
{
key: 'students',
value: 'value',
label: progressChartConfig.students.label,
color: 'var(--color-students)'
}
]);
let gradeChartColors = $derived({
domain: gradeChartData.map((item) => item.group),
range: gradeChartData.map((item) => item.color)
});
let gradeSeries = $derived([
{
key: 'students',
value: 'value',
label: gradeChartConfig.students.label,
color: 'var(--color-students)'
}
]);
</script>
<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">
<div class="">
<h3 class="mb-6 text-lg font-semibold text-gray-900 dark:text-white">
{$t('analytics.student_progress_distribution')}
</h3>
@@ -111,15 +131,13 @@
<div class="animate-pulse text-gray-500 dark:text-gray-400">{$t('analytics.loading_chart')}</div>
</div>
{:else if hasStudentData}
<Chart.ChartContainer class="h-[300px] w-full" config={chartConfig}>
<Chart.ChartContainer class="h-[300px] w-full" config={progressChartConfig}>
<Chart.BarChart
data={progressChartData}
x="group"
y="value"
c="group"
cDomain={progressChartColors.domain}
cRange={progressChartColors.range}
axis="x"
props={progressChartProps}
series={progressSeries}
/>
</Chart.ChartContainer>
{:else}
@@ -130,23 +148,15 @@
</div>
<!-- Grade Distribution Chart -->
<div class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800">
<div class="">
<h3 class="mb-6 text-lg font-semibold text-gray-900 dark:text-white">{$t('analytics.grade_distribution')}</h3>
{#if !courseAnalytics}
<div class="flex h-[300px] items-center justify-center">
<div class="animate-pulse text-gray-500 dark:text-gray-400">{$t('analytics.loading_chart')}</div>
</div>
{:else if hasStudentData}
<Chart.ChartContainer class="h-[300px] w-full" config={chartConfig}>
<Chart.BarChart
data={gradeChartData}
x="group"
y="value"
c="group"
cDomain={gradeChartColors.domain}
cRange={gradeChartColors.range}
props={gradeChartProps}
/>
<Chart.ChartContainer class="h-[300px] w-full" config={gradeChartConfig}>
<Chart.BarChart data={gradeChartData} x="group" axis="x" props={gradeChartProps} series={gradeSeries} />
</Chart.ChartContainer>
{:else}
<div class="flex h-[300px] items-center justify-center text-gray-500 dark:text-gray-400">
@@ -1,8 +1,11 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { Badge } from '@cio/ui/base/badge';
import { Button } from '@cio/ui/base/button';
import UserIcon from '@lucide/svelte/icons/user';
import * as Pagination from '@cio/ui/base/pagination';
import * as Table from '@cio/ui/base/table';
import { courseApi } from '$features/course/api';
import { t } from '$lib/utils/functions/translations';
@@ -33,7 +36,9 @@
const GotoFullProfile = (student: CourseAnalytics['students'][number]) => {
if (!student) return;
goto(`/courses/${courseApi.course?.id}/people/${student.id}?back=/courses/${courseApi.course?.id}/analytics`);
goto(
resolve(`/courses/${courseApi.course?.id}/people/${student.id}?back=/courses/${courseApi.course?.id}/analytics`)
);
};
// Reset to first page when students array changes
@@ -52,116 +57,86 @@
/>
{: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">
<th
class="sticky top-0 left-0 z-20 min-w-[200px] bg-white px-4 py-3 text-left text-sm font-medium whitespace-nowrap text-gray-500 dark:bg-neutral-800 dark:text-gray-300"
<div class="relative h-[300px] overflow-auto rounded-md border">
<Table.Root class="min-w-[800px]">
<Table.Header class="sticky top-0 z-10 bg-white dark:bg-neutral-800">
<Table.Row>
<Table.Head class="sticky top-0 left-0 z-20 min-w-[200px] bg-white px-4 py-3 dark:bg-neutral-800">
{$t('analytics.student_name')}
</Table.Head>
<Table.Head class="min-w-[150px] px-4 py-3">{$t('analytics.lessons_completed')}</Table.Head>
<Table.Head class="min-w-[140px] px-4 py-3">{$t('analytics.exercises_submitted')}</Table.Head>
<Table.Head class="min-w-[120px] px-4 py-3">{$t('analytics.average_grade')}</Table.Head>
<Table.Head class="min-w-[120px] px-4 py-3">{$t('analytics.last_seen')}</Table.Head>
<Table.Head class="min-w-[120px] px-4 py-3">{$t('analytics.actions')}</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each paginatedStudents as student (student.id)}
<Table.Row class="group h-[100px]">
<Table.Cell
class="sticky left-0 z-10 min-w-[200px] bg-white px-4 py-3 transition-colors group-hover:bg-gray-50 dark:bg-neutral-800 dark:group-hover:bg-gray-700"
>
{$t('analytics.student_name')}
</th>
<th
class="min-w-[150px] px-4 py-3 text-left text-sm font-medium whitespace-nowrap text-gray-500 dark:text-gray-300"
>
{$t('analytics.lessons_completed')}
</th>
<th
class="min-w-[140px] px-4 py-3 text-left text-sm font-medium whitespace-nowrap text-gray-500 dark:text-gray-300"
>
{$t('analytics.exercises_submitted')}
</th>
<th
class="min-w-[120px] px-4 py-3 text-left text-sm font-medium whitespace-nowrap text-gray-500 dark:text-gray-300"
>
{$t('analytics.average_grade')}
</th>
<th
class="min-w-[120px] px-4 py-3 text-left text-sm font-medium whitespace-nowrap text-gray-500 dark:text-gray-300"
>
{$t('analytics.last_seen')}
</th>
<th
class="min-w-[120px] px-4 py-3 text-left text-sm font-medium whitespace-nowrap 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] bg-white px-4 py-3 whitespace-nowrap 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.Root class="size-8">
<Avatar.Image
src={student.profile.avatar_url ? student.profile.avatar_url : '/logo-192.png'}
alt={student.profile.fullname ? student.profile.fullname : $t('analytics.student')}
/>
<Avatar.Fallback>{shortenName(student.profile.fullname) || 'S'}</Avatar.Fallback>
</Avatar.Root>
<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 class="flex items-center gap-3">
<Avatar.Root class="size-8">
<Avatar.Image
src={student.profile.avatar_url ? student.profile.avatar_url : '/logo-192.png'}
alt={student.profile.fullname ? student.profile.fullname : $t('analytics.student')}
/>
<Avatar.Fallback>{shortenName(student.profile.fullname) || 'S'}</Avatar.Fallback>
</Avatar.Root>
<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>
</td>
<!-- Scrollable Content -->
<td class="min-w-[150px] px-4 py-3 whitespace-nowrap">
<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] px-4 py-3 whitespace-nowrap">
</div>
</Table.Cell>
<Table.Cell class="min-w-[150px] px-4 py-3">
<div class="flex items-center gap-3">
<span class="text-sm font-medium text-gray-900 dark:text-white">
{student.exercisesSubmitted} out of {student.totalExercises}
{student.lessonsCompleted}/{student.totalLessons}
</span>
</td>
<td class="min-w-[120px] px-4 py-3 whitespace-nowrap">
<Badge
class={student.averageGrade >= 80
? 'bg-green-600'
: student.averageGrade >= 60
? 'bg-blue-600'
: 'bg-red-600'}
>
{student.averageGrade}%
</Badge>
</td>
<td class="min-w-[120px] px-4 py-3 whitespace-nowrap">
<span class="text-sm text-gray-500 dark:text-gray-400">
{student.lastSeen}
</span>
</td>
<td class="min-w-[120px] px-4 py-3 whitespace-nowrap">
<button
class="rounded bg-blue-600 px-3 py-1.5 text-sm font-medium whitespace-nowrap text-white transition-colors hover:bg-blue-700"
onclick={() => GotoFullProfile(student)}
>
{$t('analytics.view_details')}
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<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>
</Table.Cell>
<Table.Cell class="min-w-[140px] px-4 py-3">
<span class="text-sm font-medium text-gray-900 dark:text-white">
{student.exercisesSubmitted} out of {student.totalExercises}
</span>
</Table.Cell>
<Table.Cell class="min-w-[120px] px-4 py-3">
<Badge
class={student.averageGrade >= 80
? 'bg-green-600'
: student.averageGrade >= 60
? 'bg-blue-600'
: 'bg-red-600'}
>
{student.averageGrade}%
</Badge>
</Table.Cell>
<Table.Cell class="min-w-[120px] px-4 py-3">
<span class="text-sm text-gray-500 dark:text-gray-400">
{student.lastSeen}
</span>
</Table.Cell>
<Table.Cell class="min-w-[120px] px-4 py-3">
<Button variant="outline" size="sm" onclick={() => GotoFullProfile(student)}>
{$t('analytics.view_details')}
</Button>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
<!-- Pagination Controls -->