diff --git a/packages/alea-frontend/components/ProblemList.tsx b/packages/alea-frontend/components/ProblemList.tsx index 63cfa56c5..1eb7d8938 100644 --- a/packages/alea-frontend/components/ProblemList.tsx +++ b/packages/alea-frontend/components/ProblemList.tsx @@ -19,6 +19,7 @@ import { getExamsForCourse } from '@alea/spec'; import { ExamSelect } from '@alea/stex-react-renderer'; import { useCourseProblemCounts } from '../hooks/useCourseProblemCount'; import shadows from '../theme/shadows'; +import { getQuizzesForCourse } from '@alea/spec'; interface TitleMetadata { uri?: string; @@ -72,6 +73,9 @@ const sortExamsByDateDesc = (exams: ExamInfo[]): ExamInfo[] => { const ProblemList: FC = ({ courseSections, courseId }) => { const [exams, setExams] = useState([]); const [selectedExam, setSelectedExam] = useState(''); + + const [quizzes, setQuizzes] = useState([]); + const [selectedQuiz, setSelectedQuiz] = useState(''); const router = useRouter(); const { practiceProblems: t, peerGrading: g } = getLocaleObject(router); const theme = useTheme(); @@ -87,6 +91,18 @@ const ProblemList: FC = ({ courseSections, courseId }) => { .catch(console.error); }, [courseId]); + useEffect(() => { + if (!courseId) return; + + getQuizzesForCourse(courseId) + .then((data) => { + console.log("QUIZ DATA:", data); + const sorted = sortExamsByDateDesc(data); + setQuizzes(sorted); + }) + .catch(console.error); + }, [courseId]); + const { data: problemCounts = {}, isLoading } = useCourseProblemCounts(courseId); if (isLoading) { @@ -166,7 +182,7 @@ const ProblemList: FC = ({ courseSections, courseId }) => { bgcolor: 'bacground.paper', borderRadius: '12px', border: '1px solid ', - borderColor:'divider', + borderColor: 'divider', boxShadow: shadows[2], }} > @@ -200,6 +216,21 @@ const ProblemList: FC = ({ courseSections, courseId }) => { }} label="Select Exam" /> + + { + setSelectedQuiz(quizUri); + + router.push({ + pathname: '/quiz-problems', + query: { quizUri, courseId }, + }); + }} + label="Select Quiz" + /> @@ -320,4 +351,4 @@ const ProblemList: FC = ({ courseSections, courseId }) => { ); }; -export default ProblemList; \ No newline at end of file +export default ProblemList; diff --git a/packages/alea-frontend/pages/api/get-problems-per-section.ts b/packages/alea-frontend/pages/api/get-problems-per-section.ts index c77bfe29e..0b88eeae9 100644 --- a/packages/alea-frontend/pages/api/get-problems-per-section.ts +++ b/packages/alea-frontend/pages/api/get-problems-per-section.ts @@ -1,7 +1,12 @@ import { NextApiRequest, NextApiResponse } from 'next'; import { getAllCoursesFromDb } from './get-all-courses'; import { getCategorizedProblems } from './get-categorized-problem'; -import { getExamsForCourse, getProblemsForExam } from '@alea/spec'; +import { + getExamsForCourse, + getProblemsForExam, + getProblemsForQuiz, + getQuizzesForCourse, +} from '@alea/spec'; import { Language } from '@alea/utils'; export default async function handler(req: NextApiRequest, res: NextApiResponse) { @@ -30,11 +35,13 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const sectionProblemSet = new Set(practiceProblems.map((p) => p.problemId)); + const quizzes = await getQuizzesForCourse(courseId); + const quizProblemMap = new Map(); + for (const exam of exams) { const examProblems = await getProblemsForExam(exam.uri); for (const problemUri of examProblems) { - if (!sectionProblemSet.has(problemUri)) continue; examOnlyProblemSet.add(problemUri); @@ -49,6 +56,23 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } } + for (const quiz of quizzes) { + const quizProblems = await getProblemsForQuiz(quiz.uri); + + for (const problemUri of quizProblems) { + if (!sectionProblemSet.has(problemUri)) continue; + + const existing = quizProblemMap.get(problemUri) ?? []; + + existing.push({ + quizUri: quiz.uri, + quizLabel: quiz.number ? `Quiz ${quiz.number}` : 'Quiz', + }); + + quizProblemMap.set(problemUri, existing); + } + } + const practiceProblemSet = new Set(practiceProblems.map((p) => p.problemId)); const examOnlyProblems = Array.from(examOnlyProblemSet) @@ -64,6 +88,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const enrichedProblems = allProblems.map((p) => ({ ...p, examRefs: examProblemMap.get(p.problemId) ?? [], + quizRefs: quizProblemMap.get(p.problemId) ?? [], })); return res.status(200).json(enrichedProblems); diff --git a/packages/alea-frontend/pages/quiz-problems.tsx b/packages/alea-frontend/pages/quiz-problems.tsx new file mode 100644 index 000000000..e9c6b9b20 --- /dev/null +++ b/packages/alea-frontend/pages/quiz-problems.tsx @@ -0,0 +1,166 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useRouter } from 'next/router'; +import { Box, Chip, CircularProgress, Tooltip, Typography } from '@mui/material'; + +import { + FTMLProblemWithSolution, + getProblemsForQuiz, + formatQuizLabelShortFromUri, + formatQuizLabelFullFromUri, + getQuizMetadataByUri, +} from '@alea/spec'; + +import { + AnswerContext, + GradingContext, + QuizDisplay, + ShowGradingFor, +} from '@alea/stex-react-renderer'; + +import MainLayout from '../layouts/MainLayout'; +import { contentFragment } from '@flexiformal/ftml-backend'; + +async function buildFTMLProblem(problemUri: string): Promise { + const fragmentResponse: any[] = await contentFragment({ uri: problemUri }); + return { + problem: { + uri: problemUri, + html: fragmentResponse[2], + title_html: '', + }, + answerClasses: [], + }; +} + +async function buildQuizProblems( + problemUris: string[] +): Promise> { + const result: Record = {}; + await Promise.all( + problemUris.map(async (uri) => { + result[uri] = await buildFTMLProblem(uri); + }) + ); + return result; +} + +const QuizProblemsPage = () => { + const router = useRouter(); + const quizUri = router.query.quizUri as string | undefined; + const targetProblemId = router.query.problemId as string | undefined; + + const [quizMeta, setQuizMeta] = useState(null); + const [problems, setProblems] = useState>({}); + const [loading, setLoading] = useState(true); + const [initialIndex, setInitialIndex] = useState(0); + + useEffect(() => { + if (!quizUri) return; + + const fetchData = async () => { + setLoading(true); + try { + const decodedUri = decodeURIComponent(quizUri); + + const meta = await getQuizMetadataByUri(decodedUri); + setQuizMeta(meta); + + const uris = await getProblemsForQuiz(decodedUri); + + if (targetProblemId) { + const idx = uris.indexOf(decodeURIComponent(targetProblemId)); + if (idx !== -1) setInitialIndex(idx); + } + + const quizProblems = await buildQuizProblems(uris); + setProblems(quizProblems); + } catch (error) { + console.error('Error loading quiz data:', error); + } finally { + setLoading(false); + } + }; + + fetchData(); + }, [quizUri, targetProblemId]); + + const quizLabelShort = useMemo(() => { + if (!quizUri || !quizMeta) return ''; + return formatQuizLabelShortFromUri(quizUri, quizMeta); + }, [quizUri, quizMeta]); + + const quizLabelFull = useMemo(() => { + if (!quizUri || !quizMeta) return ''; + return formatQuizLabelFullFromUri(quizUri, quizMeta); + }, [quizUri, quizMeta]); + + if (loading) { + return ( + + + + + + ); + } + + return ( + + + + {quizLabelShort && ( + + + {quizLabelFull} + + + This problem belongs to this quiz + + + } + placement="left" + arrow + > + + + )} + + + + + + + + + + ); +}; + +export default QuizProblemsPage; diff --git a/packages/stex-react-renderer/src/lib/ExamSelect.tsx b/packages/stex-react-renderer/src/lib/ExamSelect.tsx index 3cd784bc2..2bde0ecc0 100644 --- a/packages/stex-react-renderer/src/lib/ExamSelect.tsx +++ b/packages/stex-react-renderer/src/lib/ExamSelect.tsx @@ -25,8 +25,6 @@ export function ExamSelect({ label = 'Appeared in exams', size = 'small', }: ExamSelectProps) { - if (!exams.length) return null; - return ( {label} @@ -45,7 +43,7 @@ export function ExamSelect({ }} > - Select exam + {exams.length ? 'Select' : 'No items available'} {exams.map((exam) => { diff --git a/packages/stex-react-renderer/src/lib/PerSectionQuiz.tsx b/packages/stex-react-renderer/src/lib/PerSectionQuiz.tsx index 626de4d5b..a169b43b4 100644 --- a/packages/stex-react-renderer/src/lib/PerSectionQuiz.tsx +++ b/packages/stex-react-renderer/src/lib/PerSectionQuiz.tsx @@ -19,6 +19,7 @@ import { getProblemsPerSection, getUserProfile, ProblemData, + formatQuizLabelDropdown, } from '@alea/spec'; import { getParamFromUri } from '@alea/utils'; import Router, { useRouter } from 'next/router'; @@ -200,10 +201,13 @@ export function PerSectionQuiz({ return problems.find((p) => p.problemId === problemUri); }, [problems, problemUri]); - const [selectedExamUri, setSelectedExamUri] = useState(null); + const [selectedRef, setSelectedRef] = useState<{ + uri: string; + type: 'exam' | 'quiz'; + } | null>(null); useEffect(() => { - setSelectedExamUri(null); + setSelectedRef(null); }, [problemUri]); const examOptions = useMemo(() => { @@ -222,6 +226,47 @@ export function PerSectionQuiz({ return Array.from(seen.values()); }, [currentProblem]); + const quizOptions = useMemo<{ uri: string }[]>(() => { + if (!currentProblem?.quizRefs) return []; + + const seen = new Map(); + + currentProblem.quizRefs.forEach((q) => { + if (!seen.has(q.quizUri)) { + seen.set(q.quizUri, { uri: q.quizUri }); + } + }); + + return Array.from(seen.values()); + }, [currentProblem]); + + const combinedOptions = useMemo(() => { + const exams = examOptions.map((e) => ({ + uri: e.uri, + type: 'exam' as const, + })); + + const quizzes = quizOptions.map((q) => ({ + uri: q.uri, + type: 'quiz' as const, + })); + + return [...exams, ...quizzes]; + }, [examOptions, quizOptions]); + + const dropdownLabel = useMemo(() => { + if (examOptions.length > 0 && quizOptions.length > 0) { + return 'Appeared in exams & quizzes'; + } + if (examOptions.length > 0) { + return 'Appeared in exams'; + } + if (quizOptions.length > 0) { + return 'Appeared in quizzes'; + } + return ''; + }, [examOptions, quizOptions]); + useEffect(() => { if (cachedProblemUris?.length) { setProblemUris(cachedProblemUris); @@ -323,14 +368,12 @@ export function PerSectionQuiz({ // TODO ALEA4-P3 const response = responses[problemIdx]; // const solutions = problems[problemIdx]?.subProblemData?.map((p) => p.solution); - if (!problemUri) return null; const handleApplyFilter = (filtered: string[], type: string) => { let finalUris = filtered; if (type === 'exam') { finalUris = examProblemIds; } setProblemUris(finalUris); - setAllProblemUris(finalUris); setIsSubmitted(finalUris.map(() => false)); setResponses(finalUris.map(() => undefined)); setProblemIdx(0); @@ -468,27 +511,38 @@ export function PerSectionQuiz({ my: 2, }} > - {examOptions.length > 0 && !selectedExamUri && ( - + {combinedOptions.length > 0 && !selectedRef && ( + ({ uri: o.uri }))} courseId={courseId} value="" - onChange={(uri) => setSelectedExamUri(uri)} - label="Appeared in exams" + onChange={(uri) => { + const found = combinedOptions.find((o) => o.uri === uri); + if (found) { + setSelectedRef(found); + } + }} + label={dropdownLabel} size="small" /> )} - {selectedExamUri && ( + {selectedRef && ( setSelectedExamUri(null)} + label={ + selectedRef.type === 'exam' + ? formatExamLabelDropdown(selectedRef.uri, undefined, courseId) + : formatQuizLabelDropdown(selectedRef.uri, undefined, courseId) + } + color={selectedRef.type === 'exam' ? 'error' : 'primary'} + onDelete={() => setSelectedRef(null)} onClick={() => window.open( - `/exam-problems?examUri=${encodeURIComponent(selectedExamUri)}`, + selectedRef.type === 'exam' + ? `/exam-problems?examUri=${encodeURIComponent(selectedRef.uri)}` + : `/quiz-problems?quizUri=${encodeURIComponent(selectedRef.uri)}`, '_blank' ) } @@ -513,7 +567,7 @@ export function PerSectionQuiz({ (currentProblem?.outOfSyllabusConcepts?.length ? ( +
This problem contains concepts that were not covered in the course:
diff --git a/spec/src/lib/exam-utils.ts b/spec/src/lib/exam-utils.ts index a913fa689..865026b36 100644 --- a/spec/src/lib/exam-utils.ts +++ b/spec/src/lib/exam-utils.ts @@ -69,3 +69,44 @@ export function formatExamLabelFull(examUri?: string, exam?: any, courseId?: str .filter(Boolean) .join(' '); } + +export function getQuizMeta( + quizUri?: string, + quiz?: { term?: string; number?: string; date?: string }, + courseId?: string +) { + if (!quizUri) return null; + + const decodedUri = decodeURIComponent(quizUri); + + let courseAcronym = ''; + if (courseId) { + const normalized = courseId.toLowerCase(); + courseAcronym = normalized === 'ai' ? 'AI-1' : normalized.toUpperCase(); + } + + const rawTerm = quiz?.term || ''; + const formattedTerm = rawTerm?.replace(/([A-Z]+)(\d{2})(\d{2})/, '$1 $2/$3'); + + const quizNumber = quiz?.number ? `Quiz ${quiz.number}` : 'Quiz'; + + return { + courseAcronym, + quizNumber, + formattedTerm, + }; +} + +export function formatQuizLabelDropdown(quizUri?: string, quiz?: any, courseId?: string) { + const meta = getQuizMeta(quizUri, quiz, courseId); + if (!meta) return ''; + + return [meta.quizNumber, meta.formattedTerm].filter(Boolean).join(' '); +} + +export function formatQuizLabelFull(quizUri?: string, quiz?: any, courseId?: string) { + const meta = getQuizMeta(quizUri, quiz, courseId); + if (!meta) return ''; + + return [meta.courseAcronym, meta.quizNumber, meta.formattedTerm].filter(Boolean).join(' '); +} diff --git a/spec/src/lib/flams.ts b/spec/src/lib/flams.ts index 51276f5b2..1c04fa2ca 100644 --- a/spec/src/lib/flams.ts +++ b/spec/src/lib/flams.ts @@ -515,6 +515,156 @@ export async function getExamsForCourse(courseId: string) { return Array.from(seen.values()); } +export interface QuizMeta { + courseAcronym: string; + quizNumber: string; + formattedTerm: string; + formattedDate?: string; +} + +export function getQuizMetaFromQuizUri( + quizUri: string, + quiz?: { + term?: string; + number?: string; + date?: string; + courseId?: string; + } +): QuizMeta { + const decoded = decodeURIComponent(quizUri); + + const courseAcronym = quiz?.courseId?.toUpperCase() ?? 'UNKNOWN'; + + const rawTerm = quiz?.term ?? ''; + const formattedTerm = rawTerm.replace(/([A-Z]+)(\d{2})(\d{2})/, '$1 $2/$3'); + + const quizNumber = quiz?.number ? `Quiz ${quiz.number}` : 'Quiz'; + + const formattedDate = quiz?.date ? new Date(quiz.date).toLocaleDateString('en-GB') : undefined; + + return { + courseAcronym, + quizNumber, + formattedTerm, + formattedDate, + }; +} + +export function formatQuizLabelShortFromUri(quizUri: string, quiz?: any) { + const meta = getQuizMetaFromQuizUri(quizUri, quiz); + + return [meta.courseAcronym, meta.quizNumber, meta.formattedTerm].filter(Boolean).join(' '); +} + +export function formatQuizLabelFullFromUri(quizUri: string, quiz?: any) { + const meta = getQuizMetaFromQuizUri(quizUri, quiz); + + return [ + meta.courseAcronym, + meta.quizNumber, + meta.formattedTerm, + meta.formattedDate ? `from ${meta.formattedDate}` : '', + ] + .filter(Boolean) + .join(' '); +} + +export async function getQuizMetadataByUri(quizUri: string) { + if (!quizUri) return null; + + const query = ` +PREFIX rdf: +PREFIX ulo: + +SELECT DISTINCT ?courseId ?term ?number ?date +WHERE { + <${quizUri}> rdf:type ulo:quiz . + OPTIONAL { <${quizUri}> ulo:has-course ?courseId } + OPTIONAL { <${quizUri}> ulo:has-course-term ?term } + OPTIONAL { <${quizUri}> ulo:is-number ?number } + OPTIONAL { <${quizUri}> ulo:has-date ?date } +} + `; + + const results = await getParameterizedQueryResults(query); + const b = results?.results?.bindings?.[0]; + + if (!b) return null; + + return { + courseId: b['courseId']?.value, + term: b['term']?.value, + number: b['number']?.value, + date: b['date']?.value, + }; +} + +export function buildGetQuizzesForCourseQuery(courseId: string): string { + const normalizedCourseId = courseId?.toUpperCase().split('-')[0]; + console.log('Quiz query using course:', normalizedCourseId); + + return ` +PREFIX rdf: +PREFIX ulo: + +SELECT DISTINCT ?quiz ?term ?number ?date +WHERE { + ?quiz rdf:type ulo:quiz ; + ulo:has-course "${normalizedCourseId}" . + + OPTIONAL { ?quiz ulo:has-course-term ?term } + OPTIONAL { ?quiz ulo:is-number ?number } + OPTIONAL { ?quiz ulo:has-date ?date } +} +ORDER BY DESC(?date) +`; +} + +export async function getQuizzesForCourse(courseId: string) { + if (!courseId) return []; + + const query = buildGetQuizzesForCourseQuery(courseId); + const results = await getParameterizedQueryResults(query); + + const seen = new Map(); + + for (const b of results?.results?.bindings ?? []) { + const quizUri = b['quiz']?.value; + if (!quizUri) continue; + + if (!seen.has(quizUri)) { + seen.set(quizUri, { + uri: quizUri, + term: b['term']?.value, + number: b['number']?.value, + date: b['date']?.value, + }); + } + } + + return Array.from(seen.values()); +} + +export const TEMPL_GET_PROBLEMS_FOR_QUIZ = ` +PREFIX rdf: +PREFIX ulo: + +SELECT DISTINCT ?prob WHERE { + <_uri_quiz> (ulo:contains|dc:hasPart)* ?prob. + ?prob rdf:type ulo:problem. +} +`; + +export async function getProblemsForQuiz(quizUri: string): Promise { + if (!quizUri) return []; + + const results = await getParameterizedQueryResults(TEMPL_GET_PROBLEMS_FOR_QUIZ, { + _uri_quiz: quizUri, + }); + + return results?.results?.bindings.map((b) => b['prob']?.value) ?? []; +} + export async function getProblemObjects(problemIdSubstr: string) { if (!problemIdSubstr) return []; const results = await getParameterizedQueryResults(TEMPL_GET_PROBLEM_OBJECTS_BY_SUBSTR, { diff --git a/spec/src/lib/problem-api.ts b/spec/src/lib/problem-api.ts index 319a30a43..77673e5be 100644 --- a/spec/src/lib/problem-api.ts +++ b/spec/src/lib/problem-api.ts @@ -5,11 +5,17 @@ export interface ExamRef { examLabel: string; } +export interface QuizRef { + quizUri: string; + quizLabel: string; +} + export interface ProblemData { problemId: string; category: string; labels: string[]; examRefs?: ExamRef[]; + quizRefs?: QuizRef[]; showForeignLanguageNotice?: boolean; matchedLanguage?: string; outOfSyllabusConcepts?: string[];