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:
@@ -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}
|
||||
+13
-1
@@ -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"
|
||||
|
||||
+30
-2
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user