feat: widgets and ai chat

This commit is contained in:
Rotimi Best
2026-04-27 11:40:38 +01:00
parent ae1801f85f
commit 5717dd8004
391 changed files with 28177 additions and 22467 deletions
@@ -0,0 +1,128 @@
import type { TCourseType } from '@cio/db/types';
import type { TComplianceSettings } from '@cio/utils/validation/course';
import type { UserEnrolledCourses } from '$features/course/types';
export const DEFAULT_COMPLIANCE_SETTINGS = {
retakeIntervalMonths: 12,
gracePeriodDays: 0,
reminderDaysBefore: [30, 7, 1],
isMandatory: true,
framework: null,
maxRetakeAttempts: null,
passingScore: 80
} satisfies TComplianceSettings;
export function isSelfPacedLikeCourse(courseType: TCourseType | null | undefined): boolean {
return courseType === 'SELF_PACED' || courseType === 'COMPLIANCE';
}
type StudentComplianceStatus =
| 'not_started'
| 'in_progress'
| 'compliant'
| 'expiring_soon'
| 'in_grace_period'
| 'non_compliant'
| 'waived'
| null
| undefined;
type StudentEnrolledCourse = UserEnrolledCourses[number];
function getCourseContentProgress(course: StudentEnrolledCourse) {
const exercisesCompleted = typeof course.exercisesCompleted === 'number' ? course.exercisesCompleted : 0;
const totalExercises = typeof course.exerciseCount === 'number' ? course.exerciseCount : 0;
const totalItems = (course.lessonCount ?? 0) + totalExercises;
if (totalItems <= 0) {
return 0;
}
const completedItems = (course.progressRate ?? 0) + exercisesCompleted;
return Math.round((completedItems / totalItems) * 100);
}
export function getStudentCourseComplianceStatus(course: StudentEnrolledCourse): StudentComplianceStatus {
if (course.type !== 'COMPLIANCE') {
return null;
}
if (!('complianceStatus' in course)) {
return null;
}
return course.complianceStatus as StudentComplianceStatus;
}
export function isStudentCourseComplete(course: StudentEnrolledCourse) {
if (course.type === 'COMPLIANCE') {
const complianceStatus = getStudentCourseComplianceStatus(course);
return complianceStatus === 'compliant' || complianceStatus === 'waived';
}
return getCourseContentProgress(course) >= 100;
}
export function getStudentCourseProgressPercent(course: StudentEnrolledCourse) {
return getCourseContentProgress(course);
}
export function getStudentCourseComplianceDate(course: StudentEnrolledCourse) {
if (course.type !== 'COMPLIANCE') {
return null;
}
const complianceStatus = getStudentCourseComplianceStatus(course);
const validUntil = 'complianceValidUntil' in course ? (course.complianceValidUntil ?? null) : null;
const dueDate = 'complianceDueDate' in course ? (course.complianceDueDate ?? null) : null;
if ((complianceStatus === 'compliant' || complianceStatus === 'waived') && validUntil) {
return {
labelKey: 'courses.course_card.valid_until',
value: validUntil
};
}
if (dueDate) {
return {
labelKey: 'courses.course_card.due_date',
value: dueDate
};
}
return null;
}
export function getStudentCourseComplianceStatusKey(course: StudentEnrolledCourse) {
const complianceStatus = getStudentCourseComplianceStatus(course);
return complianceStatus ? `course.navItem.compliance.status.${complianceStatus}` : null;
}
export function getStudentCourseComplianceStatusVariant(course: StudentEnrolledCourse) {
const complianceStatus = getStudentCourseComplianceStatus(course);
if (!complianceStatus) {
return 'outline';
}
if (complianceStatus === 'compliant') {
return 'success';
}
if (
complianceStatus === 'expiring_soon' ||
complianceStatus === 'in_progress' ||
complianceStatus === 'in_grace_period'
) {
return 'warning';
}
if (complianceStatus === 'non_compliant') {
return 'destructive';
}
if (complianceStatus === 'waived') {
return 'secondary';
}
return 'outline';
}
@@ -7,6 +7,12 @@ import TableOfContentsIcon from '@lucide/svelte/icons/table-of-contents';
export type ContentItem = CourseContentItem;
export type ContentSection = CourseContentSection;
export type MentionableContent = {
id: string;
title: string;
type: CourseContentType;
};
export const CONTENT_DEFINITIONS = {
[CourseContentType.Section]: {
label: 'Section',
@@ -28,7 +34,7 @@ const EMPTY_CONTENT = {
items: [] as ContentItem[]
};
export function getContentRoute(courseId: string, item: ContentItem) {
export function getContentRoute(courseId: string, item: { id: string; type: string }) {
if (item.type === CourseContentType.Lesson) {
return `/courses/${courseId}/lessons/${item.id}`;
}
@@ -37,6 +43,10 @@ export function getContentRoute(courseId: string, item: ContentItem) {
return `/courses/${courseId}/exercises/${item.id}`;
}
if (item.type === CourseContentType.Section) {
return `/courses/${courseId}/lessons#section-${item.id}`;
}
return '';
}
@@ -53,3 +63,33 @@ export function getOrderedNavigableContent(course: Course | null): ContentItem[]
NAVIGABLE_CONTENT_TYPES.includes(item.type as (typeof NAVIGABLE_CONTENT_TYPES)[number])
);
}
/**
* Flat list of mentionable content for the AI chat @ menu.
* Includes sections (when grouped) interleaved with their lessons/exercises in display order.
*/
export function getMentionableContent(course: Course | null): MentionableContent[] {
const content = getCourseContent(course);
if (!content.grouped) {
return content.items
.filter((item) => NAVIGABLE_CONTENT_TYPES.includes(item.type as (typeof NAVIGABLE_CONTENT_TYPES)[number]))
.map((item) => ({ id: item.id, title: item.title, type: item.type as CourseContentType }));
}
const result: MentionableContent[] = [];
for (const section of content.sections) {
if (section.id !== 'ungrouped' && section.title) {
result.push({ id: section.id, title: section.title, type: CourseContentType.Section });
}
for (const item of section.items) {
if (NAVIGABLE_CONTENT_TYPES.includes(item.type as (typeof NAVIGABLE_CONTENT_TYPES)[number])) {
result.push({ id: item.id, title: item.title, type: item.type as CourseContentType });
}
}
}
return result;
}
@@ -0,0 +1,54 @@
import type { Exercise } from '$features/course/utils/types';
import type { Question } from '$features/course/types';
import { exerciseApi } from '$features/course/api';
import {
clearQuestionnaireValidation,
questionnaire,
questionnaireMetaData
} from '$features/course/components/exercise/store';
import { getQuestionTypeId, getQuestionTypeOptionById } from '$features/course/components/exercise/question-type-utils';
import { normalizeQuestionOrder } from '$features/course/components/exercise/order-utils';
export function hydrateExercisePageData(exercise: Exercise, exerciseId: string) {
let questions: Question[] = [];
if (exercise.questions && Array.isArray(exercise.questions)) {
const mappedQuestions = exercise.questions.map((question) => {
const questionType = getQuestionTypeOptionById(getQuestionTypeId(question));
return {
...question,
questionTypeId: questionType.id,
questionType
};
});
questions = normalizeQuestionOrder(mappedQuestions);
}
clearQuestionnaireValidation();
questionnaire.set({
title: exercise.title,
description: exercise.description,
dueBy: exercise.dueBy,
isTitleDirty: false,
isDescriptionDirty: false,
isDueByDirty: false,
questions,
totalSubmissions: 0,
allowMultipleAttempts: !!exercise.allowMultipleAttempts
});
questionnaireMetaData.update((metadata) => ({ ...metadata, exerciseId }));
}
export async function refreshExercisePageData(courseId: string, exerciseId: string) {
await exerciseApi.get(courseId, exerciseId);
if (!exerciseApi.exercise) return null;
hydrateExercisePageData(exerciseApi.exercise, exerciseId);
return exerciseApi.exercise;
}
@@ -6,7 +6,7 @@ type CourseSettings = {
courseTitle: string;
courseDescription: string;
grading: boolean;
type: 'LIVE_CLASS' | 'SELF_PACED';
type: 'LIVE_CLASS' | 'SELF_PACED' | 'COMPLIANCE';
allowNewStudents: boolean;
tabs: { id: number; name: string }[];
lessonDownload: boolean;
@@ -376,6 +376,31 @@ export type GetCourseAnalyticsResponse = InferResponseType<GetCourseAnalyticsReq
export type GetCourseAnalyticsSuccess = Extract<GetCourseAnalyticsResponse, { success: true }>;
export type CourseAnalytics = GetCourseAnalyticsSuccess['data'];
export type GetCourseComplianceOverviewRequest = (typeof classroomio.course)[':courseId']['compliance']['$get'];
export type GetCourseComplianceOverviewResponse = InferResponseType<GetCourseComplianceOverviewRequest>;
export type GetCourseComplianceOverviewSuccess = Extract<GetCourseComplianceOverviewResponse, { success: true }>;
export type CourseComplianceOverview = GetCourseComplianceOverviewSuccess['data'];
export type CourseComplianceLearner = CourseComplianceOverview['learners'][number];
export type GetLearnerComplianceHistoryRequest =
(typeof classroomio.course)[':courseId']['compliance']['learners'][':profileId']['$get'];
export type GetLearnerComplianceHistoryResponse = InferResponseType<GetLearnerComplianceHistoryRequest>;
export type GetLearnerComplianceHistorySuccess = Extract<GetLearnerComplianceHistoryResponse, { success: true }>;
export type LearnerComplianceHistory = GetLearnerComplianceHistorySuccess['data'];
export type LearnerComplianceRecord = LearnerComplianceHistory['history'][number];
export type ResetCourseComplianceRequest = (typeof classroomio.course)[':courseId']['compliance']['reset']['$post'];
export type ResetCourseComplianceResponse = InferResponseType<ResetCourseComplianceRequest>;
export type ResetCourseComplianceSuccess = Extract<ResetCourseComplianceResponse, { success: true }>;
export type ExtendCourseComplianceRequest = (typeof classroomio.course)[':courseId']['compliance']['extend']['$post'];
export type ExtendCourseComplianceResponse = InferResponseType<ExtendCourseComplianceRequest>;
export type ExtendCourseComplianceSuccess = Extract<ExtendCourseComplianceResponse, { success: true }>;
export type WaiveCourseComplianceRequest = (typeof classroomio.course)[':courseId']['compliance']['waive']['$post'];
export type WaiveCourseComplianceResponse = InferResponseType<WaiveCourseComplianceRequest>;
export type WaiveCourseComplianceSuccess = Extract<WaiveCourseComplianceResponse, { success: true }>;
// Derived types from Lesson
export type LessonVideoType = NonNullable<Lesson['videos']>[number]['type'];
export type LessonDocument = NonNullable<Lesson['documents']>[number];