fix: sequential progression bug in course with ordered contents (#889)

* fix: sequential progression bug in course with ordered contents

* test(utils): add course progression tests and unify navigation ordering

Reuse flattenNavigableItems in getOrderedNavigableContent so next/previous
navigation follows the same section-aware order as progression locks.
Add vitest coverage for grouped multi-section flattening and sequential
access rules.

Co-authored-by: Rotimi Best <[email protected]>

* fix: address PR review comments for course progression

Simplify grouped flatten ordering, only block progression for lessons and
exercises explicitly, integrate flattenNavigableItems in the sequential access
test, and include @cio/core watch in api:dev:full.

---------
This commit is contained in:
Richard Kenneth
2026-08-14 13:50:59 +01:00
committed by GitHub
parent d3a0e01f2b
commit 96bade402a
10 changed files with 538 additions and 230 deletions
@@ -2,6 +2,7 @@ import type { Course, CourseContentItem, CourseContentSection } from './types';
import { ExerciseIcon, LessonIcon } from '@cio/ui/custom/moving-icons';
import { ContentType as CourseContentType } from '@cio/utils/constants/content';
import { flattenNavigableItems } from '@cio/utils/functions/course-progression';
import TableOfContentsIcon from '@lucide/svelte/icons/table-of-contents';
export type ContentItem = CourseContentItem;
@@ -94,8 +95,9 @@ export function getCourseProgress(course: Course | null): ContentProgress {
export function getOrderedNavigableContent(course: Course | null): ContentItem[] {
const content = getCourseContent(course);
const items = content.grouped ? content.sections.flatMap((s) => s.items) : content.items;
return items.filter((item) =>
const flattenedItems = flattenNavigableItems(content);
return flattenedItems.filter((item) =>
NAVIGABLE_CONTENT_TYPES.includes(item.type as (typeof NAVIGABLE_CONTENT_TYPES)[number])
);
}
@@ -1,95 +1,20 @@
import { ContentType } from '@cio/utils/constants/content';
import type { Course, CourseContentItem } from './types';
type ProgressionLockReason = 'teacher_locked' | 'progression_blocked';
function sortNavigableItems(items: CourseContentItem[]): CourseContentItem[] {
return [...items].sort((left, right) => {
const orderDiff = (left.order ?? 0) - (right.order ?? 0);
if (orderDiff !== 0) return orderDiff;
const typeDiff = (left.type === ContentType.Lesson ? 0 : 1) - (right.type === ContentType.Lesson ? 0 : 1);
if (typeDiff !== 0) return typeDiff;
return new Date(left.createdAt || 0).getTime() - new Date(right.createdAt || 0).getTime();
});
}
function getNavigableItems(course: Course): CourseContentItem[] {
if (!course.content) return [];
const items = course.content.grouped
? course.content.sections.flatMap((section) => section.items)
: course.content.items;
return sortNavigableItems(
items.filter((item) => item.type === ContentType.Lesson || item.type === ContentType.Exercise)
);
}
function lessonBlocksProgression(policy: string | null | undefined): boolean {
return policy !== 'none';
}
function isItemProgressComplete(item: CourseContentItem): boolean {
return Boolean(item.isComplete);
}
function computeProgressionAccess(
navigableItems: CourseContentItem[],
progressionMode: 'free' | 'sequential'
): Map<string, { accessible: boolean; lockReason: ProgressionLockReason | null }> {
const accessById = new Map<string, { accessible: boolean; lockReason: ProgressionLockReason | null }>();
let priorBlockingComplete = true;
for (const item of navigableItems) {
const teacherLocked = !(item.isUnlocked ?? true);
const progressionLocked = progressionMode === 'sequential' && !priorBlockingComplete;
let accessible = !teacherLocked && !progressionLocked;
let lockReason: ProgressionLockReason | null = null;
if (teacherLocked) {
accessible = false;
lockReason = 'teacher_locked';
} else if (progressionLocked) {
accessible = false;
lockReason = 'progression_blocked';
}
accessById.set(item.id, { accessible, lockReason });
const complete = isItemProgressComplete(item);
const blocks = item.type === ContentType.Lesson ? lessonBlocksProgression(item.completionPolicy ?? 'manual') : true;
if (blocks && !complete) {
priorBlockingComplete = false;
}
}
return accessById;
}
function annotateContentItem(
item: CourseContentItem,
accessById: Map<string, { accessible: boolean; lockReason: ProgressionLockReason | null }>
): CourseContentItem {
const access = accessById.get(item.id);
if (!access) return item;
return {
...item,
accessible: access.accessible,
lockReason: access.lockReason
};
}
import {
annotateContentItem,
computeProgressionAccess,
flattenNavigableItems
} from '@cio/utils/functions/course-progression';
import type { Course } from './types';
/** Recomputes student progression locks on in-memory course content after completion changes. */
export function syncProgressionAccessToCourse(course: Course): Course {
if (!course.content) return course;
const progressionMode = course.metadata?.progressionMode === 'sequential' ? 'sequential' : 'free';
const accessById = computeProgressionAccess(getNavigableItems(course), progressionMode);
const navigableItems = flattenNavigableItems(course.content);
const accessById = computeProgressionAccess({
navigableItems,
progressionMode
});
if (course.content.grouped) {
return {