feat(dashboard): show course progress in student sidebar (#761)

* feat(dashboard): show course progress in student sidebar

Adds a progress ring with completed-lesson count to the course sidebar
header, per-section completion percentages in the content tree, and a
"Your progress" footer card with an overall progress bar plus a
lessons/exercises breakdown. All indicators are student-only and derive
from the existing per-item isComplete flags, so they update live when a
lesson is marked complete.

* feat(dashboard): show course progress ring in header on mobile

Extracts the sidebar footer progress card into a reusable
course-progress-card component and adds a course-progress-popover
(progress ring trigger, mirroring the org nav setup ring) to the course
header beside the Assistant button. Shown to students on mobile only,
where the sidebar footer card is hidden; tapping the ring opens a
popover with the linear progress bar and lessons/exercises breakdown.

* fix(dashboard): align progress ring subtitle with ring scope

The header ring percent covers lessons and exercises while the subtitle
only counted lessons, which contradicted each other on courses with
exercises. The subtitle now uses the same all-content counts. Also
drops a duplicate useSidebar import in favor of the namespace export.
This commit is contained in:
Rotimi Best
2026-07-16 10:19:50 +01:00
committed by GitHub
parent f3d1a885b9
commit b7711ffae2
17 changed files with 214 additions and 4 deletions
@@ -17,6 +17,7 @@
import { toggleAiAssistant } from '$features/ai-assistant/utils/store';
import { openCoursePreview } from '$features/course/utils/course-preview';
import { t } from '$lib/utils/functions/translations';
import CourseProgressPopover from './course-progress-popover.svelte';
import CoursePublishBadge from './course-publish-badge.svelte';
import CoursePublicBadge from './course-public-badge.svelte';
import CourseContextMenuContent from './course-context-menu-content.svelte';
@@ -85,6 +86,10 @@
<span class="grow"></span>
{#if $isStudentExperience}
<CourseProgressPopover class="md:hidden" />
{/if}
<Button
size="sm"
onclick={toggleAiAssistant}
@@ -0,0 +1,30 @@
<script lang="ts">
import { Progress } from '@cio/ui/base/progress';
import { t } from '$lib/utils/functions/translations';
import type { ContentProgress } from '$features/course/utils/content';
interface Props {
progress: ContentProgress;
class?: string;
}
let { progress, class: className = '' }: Props = $props();
</script>
<div class={className}>
<div class="flex items-baseline justify-between gap-2">
<span class="text-xs font-medium">{$t('course.sidebar.progress.title')}</span>
<span class="ui:text-primary text-xs font-semibold tabular-nums">{progress.percent}%</span>
</div>
<Progress value={progress.percent} max={100} class="ui:h-1.5 mt-2" />
<p class="ui:text-muted-foreground mt-2 truncate text-[11px]">
{$t('course.sidebar.progress.lessons', {
completed: progress.lessonsComplete,
total: progress.lessonsTotal
})}{#if progress.exercisesTotal > 0}
· {$t('course.sidebar.progress.exercises', {
completed: progress.exercisesComplete,
total: progress.exercisesTotal
})}{/if}
</p>
</div>
@@ -0,0 +1,27 @@
<script lang="ts">
import * as Popover from '@cio/ui/base/popover';
import { PercentRingProgress } from '@cio/ui/custom/percent-ring-progress';
import { courseApi } from '$features/course/api';
import { getCourseProgress } from '$features/course/utils/content';
import { t } from '$lib/utils/functions/translations';
import CourseProgressCard from './course-progress-card.svelte';
interface Props {
class?: string;
}
let { class: className = '' }: Props = $props();
const progress = $derived(getCourseProgress(courseApi.course));
</script>
{#if progress.total > 0}
<Popover.Root>
<Popover.Trigger class="shrink-0 {className}" aria-label={$t('course.sidebar.progress.title')}>
<PercentRingProgress value={progress.percent} size="small" />
</Popover.Trigger>
<Popover.Content class="w-72" align="end">
<CourseProgressCard {progress} />
</Popover.Content>
</Popover.Root>
{/if}
@@ -8,7 +8,7 @@
import * as Collapsible from '@cio/ui/base/collapsible';
import { ContentType } from '@cio/utils/constants/content';
import { courseApi } from '$features/course/api';
import { getContentRoute, getCourseContent } from '$features/course/utils/content';
import { getContentItemsProgress, getContentRoute, getCourseContent } from '$features/course/utils/content';
import { CircleCheckIcon } from '$features/ui/icons';
import { t } from '$lib/utils/functions/translations';
import { IconButton } from '@cio/ui/custom/icon-button';
@@ -55,6 +55,18 @@
<span class="flex-1 truncate">{section.title}</span>
<div class="ml-auto flex items-center gap-1">
{#if isStudent}
{@const sectionProgress = getContentItemsProgress(section.items)}
{#if sectionProgress.total > 0}
<span
class="shrink-0 text-[11px] font-medium tabular-nums {sectionProgress.percent === 100
? 'ui:text-green-600'
: 'ui:text-muted-foreground'}"
>
{sectionProgress.percent}%
</span>
{/if}
{/if}
{#if canOpenContentModal}
<IconButton
variant="ghost-outline"
@@ -2,25 +2,53 @@
import * as Avatar from '@cio/ui/base/avatar';
import * as Sidebar from '@cio/ui/base/sidebar';
import { Skeleton } from '@cio/ui/base/skeleton';
import { PercentRingProgress } from '@cio/ui/custom/percent-ring-progress';
import { resolve } from '$app/paths';
import { currentOrg } from '$lib/utils/store/org';
import { shortenName } from '$lib/utils/functions/string';
import { courseApi } from '$features/course/api';
import { getNavItemRoute } from '$features/course/utils/functions';
import { getCourseProgress } from '$features/course/utils/content';
import { t } from '$lib/utils/functions/translations';
interface Props {
isStudent?: boolean;
}
let { isStudent = false }: Props = $props();
const sidebar = Sidebar.useSidebar();
const courseTitle = $derived(courseApi.course?.title);
const courseId = $derived(courseApi.course?.id);
const progress = $derived(getCourseProgress(courseApi.course));
const isSidebarExpanded = $derived(sidebar.open || sidebar.isMobile);
const showProgressRing = $derived(isStudent && progress.total > 0 && isSidebarExpanded);
</script>
<Sidebar.Menu>
<Sidebar.MenuItem>
<Sidebar.MenuButton
size="sm"
size={showProgressRing ? 'lg' : 'sm'}
class="ui:data-[state=open]:bg-sidebar-accent ui:data-[state=open]:text-sidebar-accent-foreground"
>
{#snippet child({ props })}
<a href={courseId ? resolve(getNavItemRoute(courseId), {}) : '#'} {...props}>
{#if $currentOrg.name}
{#if showProgressRing}
<PercentRingProgress value={progress.percent} size="small" />
<div class="flex min-w-0 flex-1 flex-col">
{#if courseTitle}
<span class="truncate text-sm font-medium">{courseTitle}</span>
{:else}
<Skeleton class="h-4 w-24" />
{/if}
<span class="ui:text-muted-foreground truncate text-xs">
{$t('course.sidebar.progress.completed', {
completed: progress.completed,
total: progress.total
})}
</span>
</div>
{:else if $currentOrg.name}
<Avatar.Root class="flex size-6! items-center justify-center rounded-md!">
<Avatar.Image src={$currentOrg.avatarUrl} alt={$currentOrg.name} />
<Avatar.Fallback class="rounded-md! text-xs">{shortenName($currentOrg.name)}</Avatar.Fallback>
@@ -11,7 +11,9 @@
import SidebarSkeleton from '$features/ui/sidebar/sidebar-skeleton.svelte';
import PoweredBy from '$features/ui/powered-by.svelte';
import { courseApi } from '$features/course/api';
import { getCourseProgress } from '$features/course/utils/content';
import { useSidebar } from '@cio/ui/base/sidebar';
import CourseProgressCard from '$features/course/components/course-progress-card.svelte';
import { startResizablePanelDrag } from '$lib/utils/functions/resizable-panel';
import { COURSE_SIDEBAR_DEFAULT_WIDTH, COURSE_SIDEBAR_MAX_WIDTH, COURSE_SIDEBAR_MIN_WIDTH } from './constants';
@@ -45,6 +47,10 @@
let stopSidebarResize: (() => void) | null = null;
const attributionCourseSlug = $derived(courseApi.course?.slug ?? null);
const courseProgress = $derived(getCourseProgress(courseApi.course));
const showCourseProgress = $derived(
$isStudentExperience && isCourseReady && sidebar.open && !sidebar.isMobile && courseProgress.total > 0
);
function clampSidebarWidth(width: number) {
return Math.min(COURSE_SIDEBAR_MAX_WIDTH, Math.max(COURSE_SIDEBAR_MIN_WIDTH, width));
@@ -114,7 +120,7 @@
{:else}
<Sidebar.Root collapsible="icon" class="z-app-bar-elevated!" mobileOverlayClass="z-app-bar-elevated!">
<Sidebar.Header>
<CourseSidebarLogo />
<CourseSidebarLogo isStudent={$isStudentExperience} />
</Sidebar.Header>
<Sidebar.Content>
@@ -143,6 +149,12 @@
<Sidebar.Rail onclick={handleRailClick} onpointerdown={handleRailPointerDown} />
<Sidebar.Footer>
{#if showCourseProgress}
<CourseProgressCard
progress={courseProgress}
class="ui:border-sidebar-border ui:bg-sidebar-accent/50 rounded-lg border p-3"
/>
{/if}
<PoweredBy
variant="sidebar"
sidebarUtmSource="lms-course-sidebar"
@@ -56,6 +56,42 @@ export function getCourseContent(course: Course | null) {
const NAVIGABLE_CONTENT_TYPES = [CourseContentType.Lesson, CourseContentType.Exercise] as const;
export type ContentProgress = {
lessonsTotal: number;
lessonsComplete: number;
exercisesTotal: number;
exercisesComplete: number;
total: number;
completed: number;
percent: number;
};
export function getContentItemsProgress(items: ContentItem[]): ContentProgress {
const lessons = items.filter((item) => item.type === CourseContentType.Lesson);
const exercises = items.filter((item) => item.type === CourseContentType.Exercise);
const lessonsComplete = lessons.filter((item) => item.isComplete).length;
const exercisesComplete = exercises.filter((item) => item.isComplete).length;
const total = lessons.length + exercises.length;
const completed = lessonsComplete + exercisesComplete;
const percent = total === 0 ? 0 : Math.round((completed / total) * 100);
return {
lessonsTotal: lessons.length,
lessonsComplete,
exercisesTotal: exercises.length,
exercisesComplete,
total,
completed,
percent
};
}
export function getCourseProgress(course: Course | null): ContentProgress {
const content = getCourseContent(course);
const items = content.grouped ? content.sections.flatMap((section) => section.items) : content.items;
return getContentItemsProgress(items);
}
export function getOrderedNavigableContent(course: Course | null): ContentItem[] {
const content = getCourseContent(course);
const items = content.grouped ? content.sections.flatMap((s) => s.items) : content.items;
@@ -2370,6 +2370,12 @@
"title": "Overholdelse",
"due_date": "Forfalder",
"valid_until": "Gælder indtil kl"
},
"progress": {
"title": "Dine fremskridt",
"completed": "{completed} af {total} fuldført",
"lessons": "{completed} af {total} lektioner",
"exercises": "{completed} af {total} øvelser"
}
},
"navItems": {
@@ -2370,6 +2370,12 @@
"title": "Compliance",
"due_date": "Fällig",
"valid_until": "Gültig bis"
},
"progress": {
"title": "Ihr Fortschritt",
"completed": "{completed} von {total} abgeschlossen",
"lessons": "{completed} von {total} Lektionen",
"exercises": "{completed} von {total} Übungen"
}
},
"navItems": {
@@ -2005,6 +2005,12 @@
"content_count": {
"lessons": "{count} lessons",
"exercises": "{count} exercises"
},
"progress": {
"title": "Your progress",
"completed": "{completed} of {total} completed",
"lessons": "{completed} of {total} lessons",
"exercises": "{completed} of {total} exercises"
}
},
"search": {
@@ -2370,6 +2370,12 @@
"title": "Cumplimiento",
"due_date": "debido",
"valid_until": "Válido hasta"
},
"progress": {
"title": "Tu progreso",
"completed": "{completed} de {total} completados",
"lessons": "{completed} de {total} lecciones",
"exercises": "{completed} de {total} ejercicios"
}
},
"navItems": {
@@ -2370,6 +2370,12 @@
"title": "Conformité",
"due_date": "Due",
"valid_until": "Valable jusqu'au"
},
"progress": {
"title": "Votre progression",
"completed": "{completed} sur {total} terminés",
"lessons": "{completed} sur {total} leçons",
"exercises": "{completed} sur {total} exercices"
}
},
"navItems": {
@@ -2370,6 +2370,12 @@
"title": "अनुपालन",
"due_date": "देय",
"valid_until": "तक वैध है"
},
"progress": {
"title": "आपकी प्रगति",
"completed": "{total} में से {completed} पूर्ण",
"lessons": "{total} में से {completed} पाठ",
"exercises": "{total} में से {completed} अभ्यास"
}
},
"navItems": {
@@ -2370,6 +2370,12 @@
"title": "Zgodność",
"due_date": "Termin",
"valid_until": "Ważne do"
},
"progress": {
"title": "Twój postęp",
"completed": "Ukończono {completed} z {total}",
"lessons": "{completed} z {total} lekcji",
"exercises": "{completed} z {total} ćwiczeń"
}
},
"navItems": {
@@ -2370,6 +2370,12 @@
"title": "Conformidade",
"due_date": "Devido",
"valid_until": "Válido até"
},
"progress": {
"title": "Seu progresso",
"completed": "{completed} de {total} concluídos",
"lessons": "{completed} de {total} aulas",
"exercises": "{completed} de {total} exercícios"
}
},
"navItems": {
@@ -2370,6 +2370,12 @@
"title": "Соответствие",
"due_date": "Срок погашения",
"valid_until": "Действительно до"
},
"progress": {
"title": "Ваш прогресс",
"completed": "Завершено {completed} из {total}",
"lessons": "{completed} из {total} уроков",
"exercises": "{completed} из {total} упражнений"
}
},
"navItems": {
@@ -2370,6 +2370,12 @@
"title": "Tuân thủ",
"due_date": "Đến hạn",
"valid_until": "Có hiệu lực cho đến khi"
},
"progress": {
"title": "Tiến độ của bạn",
"completed": "Đã hoàn thành {completed} trong số {total}",
"lessons": "{completed} trong số {total} bài học",
"exercises": "{completed} trong số {total} bài tập"
}
},
"navItems": {