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 { toggleAiAssistant } from '$features/ai-assistant/utils/store';
import { openCoursePreview } from '$features/course/utils/course-preview'; import { openCoursePreview } from '$features/course/utils/course-preview';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
import CourseProgressPopover from './course-progress-popover.svelte';
import CoursePublishBadge from './course-publish-badge.svelte'; import CoursePublishBadge from './course-publish-badge.svelte';
import CoursePublicBadge from './course-public-badge.svelte'; import CoursePublicBadge from './course-public-badge.svelte';
import CourseContextMenuContent from './course-context-menu-content.svelte'; import CourseContextMenuContent from './course-context-menu-content.svelte';
@@ -85,6 +86,10 @@
<span class="grow"></span> <span class="grow"></span>
{#if $isStudentExperience}
<CourseProgressPopover class="md:hidden" />
{/if}
<Button <Button
size="sm" size="sm"
onclick={toggleAiAssistant} 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 * as Collapsible from '@cio/ui/base/collapsible';
import { ContentType } from '@cio/utils/constants/content'; import { ContentType } from '@cio/utils/constants/content';
import { courseApi } from '$features/course/api'; 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 { CircleCheckIcon } from '$features/ui/icons';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
import { IconButton } from '@cio/ui/custom/icon-button'; import { IconButton } from '@cio/ui/custom/icon-button';
@@ -55,6 +55,18 @@
<span class="flex-1 truncate">{section.title}</span> <span class="flex-1 truncate">{section.title}</span>
<div class="ml-auto flex items-center gap-1"> <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} {#if canOpenContentModal}
<IconButton <IconButton
variant="ghost-outline" variant="ghost-outline"
@@ -2,25 +2,53 @@
import * as Avatar from '@cio/ui/base/avatar'; import * as Avatar from '@cio/ui/base/avatar';
import * as Sidebar from '@cio/ui/base/sidebar'; import * as Sidebar from '@cio/ui/base/sidebar';
import { Skeleton } from '@cio/ui/base/skeleton'; import { Skeleton } from '@cio/ui/base/skeleton';
import { PercentRingProgress } from '@cio/ui/custom/percent-ring-progress';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import { currentOrg } from '$lib/utils/store/org'; import { currentOrg } from '$lib/utils/store/org';
import { shortenName } from '$lib/utils/functions/string'; import { shortenName } from '$lib/utils/functions/string';
import { courseApi } from '$features/course/api'; import { courseApi } from '$features/course/api';
import { getNavItemRoute } from '$features/course/utils/functions'; 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 courseTitle = $derived(courseApi.course?.title);
const courseId = $derived(courseApi.course?.id); 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> </script>
<Sidebar.Menu> <Sidebar.Menu>
<Sidebar.MenuItem> <Sidebar.MenuItem>
<Sidebar.MenuButton <Sidebar.MenuButton
size="sm" size={showProgressRing ? 'lg' : 'sm'}
class="ui:data-[state=open]:bg-sidebar-accent ui:data-[state=open]:text-sidebar-accent-foreground" class="ui:data-[state=open]:bg-sidebar-accent ui:data-[state=open]:text-sidebar-accent-foreground"
> >
{#snippet child({ props })} {#snippet child({ props })}
<a href={courseId ? resolve(getNavItemRoute(courseId), {}) : '#'} {...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.Root class="flex size-6! items-center justify-center rounded-md!">
<Avatar.Image src={$currentOrg.avatarUrl} alt={$currentOrg.name} /> <Avatar.Image src={$currentOrg.avatarUrl} alt={$currentOrg.name} />
<Avatar.Fallback class="rounded-md! text-xs">{shortenName($currentOrg.name)}</Avatar.Fallback> <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 SidebarSkeleton from '$features/ui/sidebar/sidebar-skeleton.svelte';
import PoweredBy from '$features/ui/powered-by.svelte'; import PoweredBy from '$features/ui/powered-by.svelte';
import { courseApi } from '$features/course/api'; import { courseApi } from '$features/course/api';
import { getCourseProgress } from '$features/course/utils/content';
import { useSidebar } from '@cio/ui/base/sidebar'; 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 { startResizablePanelDrag } from '$lib/utils/functions/resizable-panel';
import { COURSE_SIDEBAR_DEFAULT_WIDTH, COURSE_SIDEBAR_MAX_WIDTH, COURSE_SIDEBAR_MIN_WIDTH } from './constants'; import { COURSE_SIDEBAR_DEFAULT_WIDTH, COURSE_SIDEBAR_MAX_WIDTH, COURSE_SIDEBAR_MIN_WIDTH } from './constants';
@@ -45,6 +47,10 @@
let stopSidebarResize: (() => void) | null = null; let stopSidebarResize: (() => void) | null = null;
const attributionCourseSlug = $derived(courseApi.course?.slug ?? 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) { function clampSidebarWidth(width: number) {
return Math.min(COURSE_SIDEBAR_MAX_WIDTH, Math.max(COURSE_SIDEBAR_MIN_WIDTH, width)); return Math.min(COURSE_SIDEBAR_MAX_WIDTH, Math.max(COURSE_SIDEBAR_MIN_WIDTH, width));
@@ -114,7 +120,7 @@
{:else} {:else}
<Sidebar.Root collapsible="icon" class="z-app-bar-elevated!" mobileOverlayClass="z-app-bar-elevated!"> <Sidebar.Root collapsible="icon" class="z-app-bar-elevated!" mobileOverlayClass="z-app-bar-elevated!">
<Sidebar.Header> <Sidebar.Header>
<CourseSidebarLogo /> <CourseSidebarLogo isStudent={$isStudentExperience} />
</Sidebar.Header> </Sidebar.Header>
<Sidebar.Content> <Sidebar.Content>
@@ -143,6 +149,12 @@
<Sidebar.Rail onclick={handleRailClick} onpointerdown={handleRailPointerDown} /> <Sidebar.Rail onclick={handleRailClick} onpointerdown={handleRailPointerDown} />
<Sidebar.Footer> <Sidebar.Footer>
{#if showCourseProgress}
<CourseProgressCard
progress={courseProgress}
class="ui:border-sidebar-border ui:bg-sidebar-accent/50 rounded-lg border p-3"
/>
{/if}
<PoweredBy <PoweredBy
variant="sidebar" variant="sidebar"
sidebarUtmSource="lms-course-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; 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[] { export function getOrderedNavigableContent(course: Course | null): ContentItem[] {
const content = getCourseContent(course); const content = getCourseContent(course);
const items = content.grouped ? content.sections.flatMap((s) => s.items) : content.items; const items = content.grouped ? content.sections.flatMap((s) => s.items) : content.items;
@@ -2370,6 +2370,12 @@
"title": "Overholdelse", "title": "Overholdelse",
"due_date": "Forfalder", "due_date": "Forfalder",
"valid_until": "Gælder indtil kl" "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": { "navItems": {
@@ -2370,6 +2370,12 @@
"title": "Compliance", "title": "Compliance",
"due_date": "Fällig", "due_date": "Fällig",
"valid_until": "Gültig bis" "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": { "navItems": {
@@ -2005,6 +2005,12 @@
"content_count": { "content_count": {
"lessons": "{count} lessons", "lessons": "{count} lessons",
"exercises": "{count} exercises" "exercises": "{count} exercises"
},
"progress": {
"title": "Your progress",
"completed": "{completed} of {total} completed",
"lessons": "{completed} of {total} lessons",
"exercises": "{completed} of {total} exercises"
} }
}, },
"search": { "search": {
@@ -2370,6 +2370,12 @@
"title": "Cumplimiento", "title": "Cumplimiento",
"due_date": "debido", "due_date": "debido",
"valid_until": "Válido hasta" "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": { "navItems": {
@@ -2370,6 +2370,12 @@
"title": "Conformité", "title": "Conformité",
"due_date": "Due", "due_date": "Due",
"valid_until": "Valable jusqu'au" "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": { "navItems": {
@@ -2370,6 +2370,12 @@
"title": "अनुपालन", "title": "अनुपालन",
"due_date": "देय", "due_date": "देय",
"valid_until": "तक वैध है" "valid_until": "तक वैध है"
},
"progress": {
"title": "आपकी प्रगति",
"completed": "{total} में से {completed} पूर्ण",
"lessons": "{total} में से {completed} पाठ",
"exercises": "{total} में से {completed} अभ्यास"
} }
}, },
"navItems": { "navItems": {
@@ -2370,6 +2370,12 @@
"title": "Zgodność", "title": "Zgodność",
"due_date": "Termin", "due_date": "Termin",
"valid_until": "Ważne do" "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": { "navItems": {
@@ -2370,6 +2370,12 @@
"title": "Conformidade", "title": "Conformidade",
"due_date": "Devido", "due_date": "Devido",
"valid_until": "Válido até" "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": { "navItems": {
@@ -2370,6 +2370,12 @@
"title": "Соответствие", "title": "Соответствие",
"due_date": "Срок погашения", "due_date": "Срок погашения",
"valid_until": "Действительно до" "valid_until": "Действительно до"
},
"progress": {
"title": "Ваш прогресс",
"completed": "Завершено {completed} из {total}",
"lessons": "{completed} из {total} уроков",
"exercises": "{completed} из {total} упражнений"
} }
}, },
"navItems": { "navItems": {
@@ -2370,6 +2370,12 @@
"title": "Tuân thủ", "title": "Tuân thủ",
"due_date": "Đến hạn", "due_date": "Đến hạn",
"valid_until": "Có hiệu lực cho đến khi" "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": { "navItems": {