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
@@ -90,7 +90,7 @@ const QUESTION_VALIDATION_RULES: Record<number, Array<(question: QuestionRuleInp
(question) => {
const settings = question.settings;
const template = String(settings?.template ?? '');
const blankCount = Math.max(0, template.split('___').length - 1);
const blankCount = (template.match(/_{3,}/g) ?? []).length;
if (blankCount < 1) {
return 'Word bank questions need at least one ___ blank in the template';
}
@@ -224,7 +224,9 @@ export const ZExerciseUpdate = z.object({
slug: ZSlug.optional(),
questions: z.array(ZExerciseUpdateQuestion).optional(),
sections: z.array(ZExerciseSection).optional(),
sectionDisplayMode: z.enum(['one_question', 'all_questions']).optional()
sectionDisplayMode: z.enum(['one_question', 'all_questions']).optional(),
completionPolicy: z.enum(['submitted', 'passed']).optional(),
passThreshold: z.number().int().min(0).max(100).optional()
});
export type TExerciseUpdate = z.infer<typeof ZExerciseUpdate>;
@@ -29,6 +29,8 @@ export const ZLessonUpdate = z.object({
public: z.boolean().optional(),
slug: ZSlug.optional(),
isComplete: z.boolean().optional(),
completionPolicy: z.enum(['manual', 'video_watch', 'none']).optional(),
videoWatchThreshold: z.number().int().min(1).max(100).optional(),
videoUrl: z.url().optional(),
slideUrl: z.url().optional(),
videos: z
@@ -38,6 +40,7 @@ export const ZLessonUpdate = z.object({
link: z.string(),
key: z.string().optional(),
assetId: z.string().uuid().optional(),
watchEnforced: z.boolean().optional(),
fileName: z.string().optional(),
metadata: z.record(z.string(), z.unknown()).optional()
})
@@ -128,3 +131,17 @@ export const ZLessonCompletionUpdate = z.object({
isComplete: z.boolean()
});
export type TLessonCompletionUpdate = z.infer<typeof ZLessonCompletionUpdate>;
export const ZUpdateLessonWatchProgress = z.object({
positionSeconds: z.number().min(0),
playedDeltaSeconds: z
.number()
.transform((value) => Math.round(value))
.pipe(z.number().min(0).max(120)),
durationSeconds: z
.number()
.transform((value) => Math.round(value))
.pipe(z.int().min(1)),
assetId: z.string().optional()
});
export type TUpdateLessonWatchProgress = z.infer<typeof ZUpdateLessonWatchProgress>;