feat: add sequential progression and video watch lesson completion

Gate student content by completion order, track per-video watch progress
with seek enforcement, and auto-complete lessons at the configured
threshold. Show ring progress in the lesson header, keep watch progress
on rewatch, and sync progression locks client-side so the next item
unlocks after completion.
This commit is contained in:
Rotimi Best
2026-06-11 14:48:34 +01:00
parent c8154409f5
commit 52d9bf18c3
94 changed files with 3499 additions and 564 deletions
@@ -9,6 +9,7 @@
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$features/ui/snackbar/store';
import { slugifyTitle } from '@cio/utils/validation';
import * as Select from '@cio/ui/base/select';
type Props = {
exerciseId: string;
@@ -31,6 +32,8 @@
await exerciseApi.update(courseApi.course.id, exerciseId, {
allowMultipleAttempts: !!$questionnaire.allowMultipleAttempts,
completionPolicy: $questionnaire.completionPolicy ?? 'submitted',
passThreshold: $questionnaire.passThreshold ?? 100,
slug: isPublicCourse && slug ? slug : undefined
});
saving = false;
@@ -60,6 +63,51 @@
/>
</div>
<div class="rounded-lg border border-neutral-200 p-4 dark:border-neutral-700">
<Label class="mb-2 block text-sm font-medium dark:text-gray-100">
{$t('course.navItem.lessons.exercises.all_exercises.settings_completion_policy')}
</Label>
<Select.Root
type="single"
value={$questionnaire.completionPolicy ?? 'submitted'}
onValueChange={(value) => {
if (value === 'submitted' || value === 'passed') {
questionnaire.update((state) => ({ ...state, completionPolicy: value }));
}
}}
>
<Select.Trigger class="w-full">
{($questionnaire.completionPolicy ?? 'submitted') === 'passed'
? $t('course.navItem.lessons.exercises.all_exercises.settings_completion_passed')
: $t('course.navItem.lessons.exercises.all_exercises.settings_completion_submitted')}
</Select.Trigger>
<Select.Content>
<Select.Item value="submitted">
{$t('course.navItem.lessons.exercises.all_exercises.settings_completion_submitted')}
</Select.Item>
<Select.Item value="passed">
{$t('course.navItem.lessons.exercises.all_exercises.settings_completion_passed')}
</Select.Item>
</Select.Content>
</Select.Root>
{#if ($questionnaire.completionPolicy ?? 'submitted') === 'passed'}
<div class="mt-4">
<InputField
type="number"
label={$t('course.navItem.lessons.exercises.all_exercises.settings_pass_threshold')}
value={String($questionnaire.passThreshold ?? 100)}
onInputChange={(event) => {
const parsed = Number(event.currentTarget.value);
if (!Number.isNaN(parsed)) {
questionnaire.update((state) => ({ ...state, passThreshold: parsed }));
}
}}
/>
</div>
{/if}
</div>
{#if isPublicCourse}
<div class="rounded-lg border border-neutral-200 p-4 dark:border-neutral-700">
<InputField
@@ -69,6 +69,8 @@ export interface QuestionnaireState {
sectionDisplayMode: ExerciseSectionDisplayMode;
totalSubmissions: number;
allowMultipleAttempts?: boolean;
completionPolicy?: 'submitted' | 'passed';
passThreshold?: number | null;
slug?: string | null;
}
@@ -102,6 +104,8 @@ export const questionnaire: Writable<QuestionnaireState> = writable({
sectionDisplayMode: 'one_question',
totalSubmissions: 0,
allowMultipleAttempts: false,
completionPolicy: 'submitted',
passThreshold: 100,
slug: ''
});