feat: widgets and ai chat
This commit is contained in:
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user