@hydralms/components 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (199) hide show
  1. package/dist/ForumBoard-CHXU3mjC.js +2207 -0
  2. package/dist/ForumBoard-d1w5-r6n.cjs +1 -0
  3. package/dist/assessment-toolbar/assessment-toolbar.d.ts +1 -1
  4. package/dist/assessment-toolbar/index.d.ts +5 -1
  5. package/dist/assessment-toolbar/question-header-bar.d.ts +2 -0
  6. package/dist/assessment-toolbar/question-materials-drawer.d.ts +2 -0
  7. package/dist/assessment-toolbar/question-navigator.d.ts +1 -1
  8. package/dist/assessment-toolbar/types.d.ts +52 -4
  9. package/dist/assessment-toolbar/use-countdown.d.ts +43 -0
  10. package/dist/common/index.d.ts +2 -1
  11. package/dist/common/stepper.d.ts +6 -0
  12. package/dist/common/types.d.ts +37 -0
  13. package/dist/components.css +1 -1
  14. package/dist/content/attachment-list.d.ts +6 -0
  15. package/dist/content/content-block.d.ts +1 -1
  16. package/dist/content/index.d.ts +2 -1
  17. package/dist/content/types.d.ts +39 -0
  18. package/dist/curriculum/curriculum-item.d.ts +1 -1
  19. package/dist/index.cjs +1 -1
  20. package/dist/index.js +551 -312
  21. package/dist/modules/AssignmentModule/AssignmentModule.d.ts +8 -0
  22. package/dist/modules/AssignmentModule/types.d.ts +65 -0
  23. package/dist/modules/CertificateModule/CertificateModule.d.ts +9 -0
  24. package/dist/modules/CertificateModule/types.d.ts +49 -0
  25. package/dist/modules/DiscussionModule/DiscussionModule.d.ts +8 -0
  26. package/dist/modules/DiscussionModule/types.d.ts +47 -0
  27. package/dist/modules/ExamModule/ExamModule.d.ts +8 -0
  28. package/dist/modules/ExamModule/types.d.ts +64 -0
  29. package/dist/modules/GradeCenterModule/GradeCenterModule.d.ts +9 -0
  30. package/dist/modules/GradeCenterModule/types.d.ts +54 -0
  31. package/dist/modules/QuizModule/QuizModule.d.ts +1 -1
  32. package/dist/modules/QuizModule/types.d.ts +6 -1
  33. package/dist/modules/SurveyModule/SurveyModule.d.ts +7 -0
  34. package/dist/modules/SurveyModule/types.d.ts +49 -0
  35. package/dist/modules/index.d.ts +12 -0
  36. package/dist/modules.cjs +1 -0
  37. package/dist/modules.js +1422 -0
  38. package/dist/progress/achievement-badge.d.ts +6 -0
  39. package/dist/progress/activity-timeline.d.ts +6 -0
  40. package/dist/progress/index.d.ts +4 -1
  41. package/dist/progress/stat-card.d.ts +1 -1
  42. package/dist/progress/streak-badge.d.ts +6 -0
  43. package/dist/progress/types.d.ts +97 -0
  44. package/dist/questions/essay.d.ts +1 -1
  45. package/dist/questions/hotspot.d.ts +21 -0
  46. package/dist/questions/index.d.ts +9 -1
  47. package/dist/questions/inline-choice.d.ts +21 -0
  48. package/dist/questions/matching.d.ts +22 -0
  49. package/dist/questions/numeric.d.ts +11 -0
  50. package/dist/questions/ordering.d.ts +12 -0
  51. package/dist/questions/scenario.d.ts +23 -0
  52. package/dist/questions/scoring.d.ts +22 -0
  53. package/dist/questions/spreadsheet.d.ts +29 -0
  54. package/dist/questions/types.d.ts +106 -1
  55. package/dist/questions/use-drag-reorder.d.ts +17 -0
  56. package/dist/sections/CertificateViewer/types.d.ts +7 -5
  57. package/dist/sections/ExamSession/ExamSession.d.ts +1 -1
  58. package/dist/sections/ExamSession/types.d.ts +6 -1
  59. package/dist/sections/ForumBoard/ForumBoard.d.ts +8 -0
  60. package/dist/sections/ForumBoard/types.d.ts +64 -0
  61. package/dist/sections/QuizSession/QuizSession.d.ts +1 -1
  62. package/dist/sections/QuizSession/types.d.ts +6 -1
  63. package/dist/sections/RequirementsChecklist/RequirementsChecklist.d.ts +8 -0
  64. package/dist/sections/RequirementsChecklist/types.d.ts +37 -0
  65. package/dist/sections/RubricView/RubricView.d.ts +9 -0
  66. package/dist/sections/RubricView/types.d.ts +50 -0
  67. package/dist/sections/index.d.ts +7 -1
  68. package/dist/sections.cjs +1 -1
  69. package/dist/sections.js +250 -1715
  70. package/dist/social/post-card.d.ts +1 -1
  71. package/dist/tabs-DRM2Iq_J.cjs +172 -0
  72. package/dist/tabs-Wf3h_Cx3.js +21580 -0
  73. package/dist/ui/alert.d.ts +1 -1
  74. package/dist/ui/badge.d.ts +1 -1
  75. package/dist/ui/button.d.ts +1 -1
  76. package/dist/ui/drawer.d.ts +84 -0
  77. package/dist/ui/index.d.ts +3 -0
  78. package/dist/ui/progress.d.ts +1 -1
  79. package/dist/ui/rich-text-editor.d.ts +30 -0
  80. package/dist/ui/rich-text-toolbar.d.ts +8 -0
  81. package/dist/utils/array-utils.d.ts +4 -0
  82. package/dist/utils/flatten-leaves.d.ts +6 -0
  83. package/dist/utils/format-file-size.d.ts +1 -0
  84. package/dist/utils/format-timestamp.d.ts +1 -0
  85. package/dist/utils/is-empty-html.d.ts +5 -0
  86. package/dist/utils/shuffle.d.ts +1 -0
  87. package/dist/utils/string-utils.d.ts +12 -0
  88. package/dist/video/video-bookmark.d.ts +1 -1
  89. package/dist/video/video-playlist-item.d.ts +1 -1
  90. package/package.json +141 -3
  91. package/src/assessment-toolbar/assessment-toolbar.tsx +54 -49
  92. package/src/assessment-toolbar/index.ts +6 -0
  93. package/src/assessment-toolbar/question-header-bar.tsx +61 -0
  94. package/src/assessment-toolbar/question-materials-drawer.tsx +55 -0
  95. package/src/assessment-toolbar/question-navigator.tsx +3 -31
  96. package/src/assessment-toolbar/timer-display.tsx +2 -2
  97. package/src/assessment-toolbar/types.ts +54 -4
  98. package/src/assessment-toolbar/use-countdown.ts +153 -0
  99. package/src/common/index.ts +3 -0
  100. package/src/common/search-input.tsx +7 -6
  101. package/src/common/stepper.tsx +100 -0
  102. package/src/common/types.ts +39 -0
  103. package/src/content/attachment-list.tsx +90 -0
  104. package/src/content/content-block.tsx +4 -2
  105. package/src/content/file-upload-zone.tsx +1 -6
  106. package/src/content/index.ts +3 -0
  107. package/src/content/types.ts +41 -0
  108. package/src/curriculum/curriculum-item.tsx +7 -3
  109. package/src/feedback/feedback-banner.tsx +12 -14
  110. package/src/flashcards/flashcard-deck.tsx +1 -9
  111. package/src/flashcards/flashcard.tsx +1 -1
  112. package/src/modules/AssignmentModule/AssignmentModule.tsx +305 -0
  113. package/src/modules/AssignmentModule/types.ts +73 -0
  114. package/src/modules/CertificateModule/CertificateModule.tsx +161 -0
  115. package/src/modules/CertificateModule/types.ts +47 -0
  116. package/src/modules/CoursePlayer/CoursePlayer.tsx +44 -48
  117. package/src/modules/DiscussionModule/DiscussionModule.tsx +110 -0
  118. package/src/modules/DiscussionModule/types.ts +54 -0
  119. package/src/modules/ExamModule/ExamModule.tsx +285 -0
  120. package/src/modules/ExamModule/types.ts +66 -0
  121. package/src/modules/FlashcardLab/FlashcardLab.tsx +29 -16
  122. package/src/modules/GradeCenterModule/GradeCenterModule.tsx +169 -0
  123. package/src/modules/GradeCenterModule/types.ts +63 -0
  124. package/src/modules/QuizModule/QuizModule.tsx +88 -88
  125. package/src/modules/QuizModule/types.ts +6 -1
  126. package/src/modules/SurveyModule/SurveyModule.tsx +180 -0
  127. package/src/modules/SurveyModule/types.ts +51 -0
  128. package/src/modules/index.ts +24 -0
  129. package/src/progress/achievement-badge.tsx +52 -0
  130. package/src/progress/activity-timeline.tsx +84 -0
  131. package/src/progress/index.ts +7 -0
  132. package/src/progress/stat-card.tsx +30 -18
  133. package/src/progress/streak-badge.tsx +35 -0
  134. package/src/progress/types.ts +101 -0
  135. package/src/questions/choice.tsx +7 -9
  136. package/src/questions/essay.tsx +23 -25
  137. package/src/questions/fill-in-the-blank.tsx +13 -16
  138. package/src/questions/hotspot.tsx +154 -0
  139. package/src/questions/index.ts +16 -0
  140. package/src/questions/inline-choice.tsx +151 -0
  141. package/src/questions/matching.tsx +228 -0
  142. package/src/questions/multiple-choice.tsx +7 -9
  143. package/src/questions/numeric.tsx +102 -0
  144. package/src/questions/ordering.tsx +159 -0
  145. package/src/questions/question-renderer.tsx +21 -0
  146. package/src/questions/scenario.tsx +140 -0
  147. package/src/questions/scoring.ts +201 -0
  148. package/src/questions/spreadsheet.tsx +259 -0
  149. package/src/questions/true-false.tsx +7 -9
  150. package/src/questions/types.ts +123 -1
  151. package/src/questions/use-drag-reorder.ts +80 -0
  152. package/src/sections/AnnouncementFeed/AnnouncementFeed.tsx +2 -15
  153. package/src/sections/AssessmentReview/AssessmentReview.tsx +13 -2
  154. package/src/sections/AssignmentSubmission/AssignmentSubmission.tsx +7 -5
  155. package/src/sections/CertificateViewer/CertificateViewer.tsx +409 -56
  156. package/src/sections/CertificateViewer/types.ts +13 -5
  157. package/src/sections/CourseOutline/CourseOutline.tsx +4 -14
  158. package/src/sections/DiscussionThread/DiscussionThread.tsx +13 -10
  159. package/src/sections/ExamSession/ExamSession.tsx +44 -7
  160. package/src/sections/ExamSession/types.ts +6 -1
  161. package/src/sections/ForumBoard/ForumBoard.tsx +284 -0
  162. package/src/sections/ForumBoard/types.ts +67 -0
  163. package/src/sections/GradebookTable/GradebookTable.tsx +1 -1
  164. package/src/sections/LecturePlayer/LecturePlayer.tsx +1 -1
  165. package/src/sections/LessonPage/LessonPage.tsx +5 -9
  166. package/src/sections/PracticeQuiz/PracticeQuiz.tsx +15 -26
  167. package/src/sections/ProgressDashboard/ProgressDashboard.tsx +65 -65
  168. package/src/sections/QuizSession/QuizSession.tsx +67 -8
  169. package/src/sections/QuizSession/types.ts +6 -1
  170. package/src/sections/RequirementsChecklist/RequirementsChecklist.tsx +107 -0
  171. package/src/sections/RequirementsChecklist/types.ts +38 -0
  172. package/src/sections/ResourceLibrary/ResourceLibrary.tsx +4 -9
  173. package/src/sections/RubricView/RubricView.tsx +138 -0
  174. package/src/sections/RubricView/types.ts +52 -0
  175. package/src/sections/ScrollableQuiz/ScrollableQuiz.tsx +23 -9
  176. package/src/sections/SurveyForm/SurveyForm.tsx +8 -5
  177. package/src/sections/index.ts +20 -1
  178. package/src/social/post-card.tsx +8 -19
  179. package/src/social/user-avatar.tsx +1 -0
  180. package/src/styles/globals.css +13 -0
  181. package/src/ui/drawer.tsx +600 -0
  182. package/src/ui/index.ts +19 -0
  183. package/src/ui/rich-text-editor.tsx +109 -0
  184. package/src/ui/rich-text-toolbar.tsx +156 -0
  185. package/src/utils/array-utils.ts +17 -0
  186. package/src/utils/flatten-leaves.ts +17 -0
  187. package/src/utils/format-file-size.ts +5 -0
  188. package/src/utils/format-timestamp.ts +13 -0
  189. package/src/utils/is-empty-html.ts +7 -0
  190. package/src/utils/shuffle.ts +8 -0
  191. package/src/utils/string-utils.ts +30 -0
  192. package/src/video/video-bookmark.tsx +4 -3
  193. package/src/video/video-chapter-list.tsx +9 -4
  194. package/src/video/video-player.tsx +11 -4
  195. package/src/video/video-playlist-item.tsx +8 -3
  196. package/src/video/video-thumbnail-card.tsx +4 -0
  197. package/src/video/video-transcript.tsx +8 -5
  198. package/dist/table-BrS5cDQu.js +0 -2510
  199. package/dist/table-D6AkBBEo.cjs +0 -1
@@ -4,6 +4,13 @@ import { Choice } from "./choice";
4
4
  import { TrueFalse } from "./true-false";
5
5
  import { FillInTheBlank } from "./fill-in-the-blank";
6
6
  import { Essay } from "./essay";
7
+ import { Numeric } from "./numeric";
8
+ import { Ordering } from "./ordering";
9
+ import { Matching } from "./matching";
10
+ import { Hotspot } from "./hotspot";
11
+ import { InlineChoice } from "./inline-choice";
12
+ import { Scenario } from "./scenario";
13
+ import { Spreadsheet } from "./spreadsheet";
7
14
 
8
15
  /**
9
16
  * QuestionRenderer dispatches to the appropriate question component based on question type.
@@ -27,6 +34,20 @@ export const QuestionRenderer = (props: QuestionProps) => {
27
34
  return <FillInTheBlank {...props} />;
28
35
  case "essay":
29
36
  return <Essay {...props} />;
37
+ case "numeric":
38
+ return <Numeric {...props} />;
39
+ case "ordering":
40
+ return <Ordering {...props} />;
41
+ case "matching":
42
+ return <Matching {...props} />;
43
+ case "hotspot":
44
+ return <Hotspot {...props} />;
45
+ case "inline_choice":
46
+ return <InlineChoice {...props} />;
47
+ case "scenario":
48
+ return <Scenario {...props} />;
49
+ case "spreadsheet":
50
+ return <Spreadsheet {...props} />;
30
51
  default:
31
52
  return (
32
53
  <p className="text-muted-foreground">
@@ -0,0 +1,140 @@
1
+ import { useMemo, useRef } from "react";
2
+ import { Alert, AlertDescription } from "../ui/alert";
3
+ import { Separator } from "../ui/separator";
4
+ import { cn } from "../lib/utils";
5
+ import { QuestionRenderer } from "./question-renderer";
6
+ import type { QuestionProps, SessionAnswer } from "./types";
7
+
8
+ /**
9
+ * Scenario renders a shared context/stimulus with multiple sub-questions.
10
+ *
11
+ * Sub-questions can be any existing question type (choice, numeric, matching, etc.).
12
+ * The shared context is displayed prominently above all sub-questions.
13
+ *
14
+ * @example
15
+ * <Scenario
16
+ * question={{
17
+ * uid: "s1",
18
+ * type: "scenario",
19
+ * content: "<p>Read the following passage and answer the questions below.</p>",
20
+ * scenarioQuestions: [
21
+ * { uid: "sq1", type: "choice", content: "What is the main idea?", answers: [...] },
22
+ * { uid: "sq2", type: "true_false", content: "The author agrees.", answers: [...] },
23
+ * ],
24
+ * scenarioScoringMode: "per_question",
25
+ * }}
26
+ * onAnswer={(answers) => handleAnswer(answers)}
27
+ * />
28
+ */
29
+ export const Scenario = ({
30
+ question,
31
+ sessionAnswers,
32
+ onAnswer,
33
+ readOnly = false,
34
+ showCorrectAnswers = false,
35
+ disabled = false,
36
+ }: QuestionProps) => {
37
+ const subQuestions = question.scenarioQuestions ?? [];
38
+
39
+ // Track the latest answers from each sub-question in a ref so
40
+ // handleSubAnswer always merges against the freshest state.
41
+ const latestBySubQ = useRef<Map<string, { uid: string; content?: string }[]>>(
42
+ new Map(),
43
+ );
44
+
45
+ // Parse session answers into per-sub-question groups.
46
+ // SessionAnswer.answerUid format: "subQUid::originalAnswerUid"
47
+ const answersBySubQ = useMemo(() => {
48
+ const map = new Map<string, SessionAnswer[]>();
49
+ for (const sa of sessionAnswers ?? []) {
50
+ const sepIdx = sa.answerUid.indexOf("::");
51
+ if (sepIdx === -1) continue;
52
+ const subQUid = sa.answerUid.slice(0, sepIdx);
53
+ const originalAnswerUid = sa.answerUid.slice(sepIdx + 2);
54
+ const list = map.get(subQUid) ?? [];
55
+ list.push({ ...sa, uid: subQUid, answerUid: originalAnswerUid });
56
+ map.set(subQUid, list);
57
+ }
58
+
59
+ // Sync the ref with what we parsed from props
60
+ latestBySubQ.current = new Map();
61
+ for (const [subQUid, answers] of map) {
62
+ latestBySubQ.current.set(
63
+ subQUid,
64
+ answers.map((a) => ({ uid: `${subQUid}::${a.answerUid}`, content: a.content })),
65
+ );
66
+ }
67
+
68
+ return map;
69
+ }, [sessionAnswers]);
70
+
71
+ const handleSubAnswer = (
72
+ subQuestionUid: string,
73
+ rawAnswers: { uid: string; content?: string }[],
74
+ ) => {
75
+ // Prefix each answer uid with the sub-question uid
76
+ const prefixed = rawAnswers.map((a) => ({
77
+ uid: `${subQuestionUid}::${a.uid}`,
78
+ content: a.content,
79
+ }));
80
+
81
+ // Update our ref
82
+ latestBySubQ.current.set(subQuestionUid, prefixed);
83
+
84
+ // Merge all sub-question answers into a single flat array
85
+ const merged: { uid: string; content?: string }[] = [];
86
+ for (const answers of latestBySubQ.current.values()) {
87
+ merged.push(...answers);
88
+ }
89
+
90
+ onAnswer?.(merged);
91
+ };
92
+
93
+ return (
94
+ <div className="flex flex-col gap-6">
95
+ {/* Shared context / stimulus */}
96
+ <div
97
+ className={cn(
98
+ "rounded-lg border border-border bg-muted/30 px-4 py-3",
99
+ "prose prose-sm max-w-none text-foreground",
100
+ )}
101
+ >
102
+ <div className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-2">
103
+ Scenario
104
+ </div>
105
+ <div dangerouslySetInnerHTML={{ __html: question.content }} />
106
+ </div>
107
+
108
+ <Separator />
109
+
110
+ {/* Sub-questions */}
111
+ <div className="flex flex-col gap-5">
112
+ {subQuestions.map((sq, idx) => (
113
+ <div key={sq.uid} className="flex flex-col gap-1">
114
+ <span className="text-xs font-medium text-muted-foreground">
115
+ Part {String.fromCharCode(65 + idx)}
116
+ </span>
117
+ <QuestionRenderer
118
+ question={sq}
119
+ sessionAnswers={answersBySubQ.get(sq.uid) ?? []}
120
+ onAnswer={(answers) => handleSubAnswer(sq.uid, answers)}
121
+ readOnly={readOnly}
122
+ showCorrectAnswers={showCorrectAnswers}
123
+ disabled={disabled}
124
+ />
125
+ </div>
126
+ ))}
127
+ </div>
128
+
129
+ {/* Scenario-level explanation */}
130
+ {showCorrectAnswers && question.explanation && (
131
+ <Alert className="mt-2">
132
+ <AlertDescription>
133
+ <strong>Scenario Explanation:</strong>{" "}
134
+ <span dangerouslySetInnerHTML={{ __html: question.explanation }} />
135
+ </AlertDescription>
136
+ </Alert>
137
+ )}
138
+ </div>
139
+ );
140
+ };
@@ -0,0 +1,201 @@
1
+ import type { QuestionData, SessionAnswer } from "./types";
2
+
3
+ /**
4
+ * Scores a single question against the user's answers.
5
+ *
6
+ * @returns `true` if correct, `false` if incorrect, `null` if the question is not auto-gradable.
7
+ */
8
+ export function scoreQuestion(
9
+ question: QuestionData,
10
+ userAnswers: SessionAnswer[],
11
+ ): boolean | null {
12
+ switch (question.type) {
13
+ case "choice":
14
+ case "multiple_choice":
15
+ case "true_false": {
16
+ const correctUids = new Set(
17
+ (question.answers ?? []).filter((a) => a.isCorrect).map((a) => a.uid),
18
+ );
19
+ const userUids = new Set(userAnswers.map((a) => a.answerUid));
20
+ if (correctUids.size === 0) return null;
21
+ return (
22
+ correctUids.size === userUids.size &&
23
+ [...correctUids].every((uid) => userUids.has(uid))
24
+ );
25
+ }
26
+
27
+ case "fill_in_the_blank":
28
+ case "essay":
29
+ return null;
30
+
31
+ case "ordering": {
32
+ const answers = question.answers ?? [];
33
+ if (answers.length === 0) return null;
34
+ const sorted = [...answers].sort((a, b) => a.sequence - b.sequence);
35
+ return sorted.every((answer, correctIndex) => {
36
+ const ua = userAnswers.find((a) => a.answerUid === answer.uid);
37
+ return ua?.content === String(correctIndex);
38
+ });
39
+ }
40
+
41
+ case "matching": {
42
+ const pairs = question.matchingPairs ?? [];
43
+ if (pairs.length === 0) return null;
44
+ return pairs.every((pair) => {
45
+ const ua = userAnswers.find((a) => a.answerUid === pair.uid);
46
+ return ua?.content === pair.uid;
47
+ });
48
+ }
49
+
50
+ case "numeric": {
51
+ if (question.numericAnswer === undefined) return null;
52
+ const raw = userAnswers[0]?.content;
53
+ if (raw === undefined || raw === "") return false;
54
+ const userNum = parseFloat(raw);
55
+ if (isNaN(userNum)) return false;
56
+ const tolerance = question.numericTolerance ?? 0;
57
+ return Math.abs(userNum - question.numericAnswer) <= tolerance;
58
+ }
59
+
60
+ case "hotspot": {
61
+ const regions = question.hotspotRegions ?? [];
62
+ const correctUids = new Set(
63
+ regions.filter((r) => r.isCorrect).map((r) => r.uid),
64
+ );
65
+ if (correctUids.size === 0) return null;
66
+ const userUids = new Set(userAnswers.map((a) => a.answerUid));
67
+ return (
68
+ correctUids.size === userUids.size &&
69
+ [...correctUids].every((uid) => userUids.has(uid))
70
+ );
71
+ }
72
+
73
+ case "inline_choice": {
74
+ const blanks = question.inlineBlanks ?? [];
75
+ if (blanks.length === 0) return null;
76
+ return blanks.every((blank) => {
77
+ const correctOption = blank.options.find((o) => o.isCorrect);
78
+ if (!correctOption) return true;
79
+ const ua = userAnswers.find((a) => a.answerUid === blank.uid);
80
+ return ua?.content === correctOption.uid;
81
+ });
82
+ }
83
+
84
+ case "spreadsheet": {
85
+ const rows = question.spreadsheetRows ?? [];
86
+ const cols = question.spreadsheetColumns ?? [];
87
+ const colMap = new Map(cols.map((c) => [c.uid, c]));
88
+ const editableCells = rows.flatMap((r) =>
89
+ r.cells.filter((c) => !c.locked && c.correctAnswer !== undefined),
90
+ );
91
+ if (editableCells.length === 0) return null;
92
+ return editableCells.every((cell) => {
93
+ const ua = userAnswers.find((a) => a.answerUid === cell.uid);
94
+ const userValue = ua?.content?.trim() ?? "";
95
+ if (!userValue) return false;
96
+ const col = colMap.get(cell.columnUid);
97
+ if (!col || col.type === "text") {
98
+ return userValue.toLowerCase() === cell.correctAnswer!.trim().toLowerCase();
99
+ }
100
+ const userNum = parseFloat(userValue.replace(/,/g, ""));
101
+ const correctNum = parseFloat(cell.correctAnswer!.replace(/,/g, ""));
102
+ if (isNaN(userNum) || isNaN(correctNum)) return false;
103
+ const tol = cell.tolerance ?? 0;
104
+ return Math.abs(userNum - correctNum) <= tol;
105
+ });
106
+ }
107
+
108
+ case "scenario": {
109
+ const subQuestions = question.scenarioQuestions ?? [];
110
+ if (subQuestions.length === 0) return null;
111
+
112
+ const mode = question.scenarioScoringMode ?? "per_question";
113
+
114
+ if (mode === "all_or_nothing") {
115
+ const grouped = groupScenarioAnswers(userAnswers);
116
+ const results = subQuestions.map((sq) => {
117
+ const sqAnswers = grouped.get(sq.uid) ?? [];
118
+ return scoreQuestion(sq, sqAnswers);
119
+ });
120
+ if (results.some((r) => r === null)) return null;
121
+ return results.every((r) => r === true);
122
+ }
123
+
124
+ // "per_question" mode has no single aggregate score
125
+ return null;
126
+ }
127
+
128
+ default:
129
+ return null;
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Scores an entire set of questions against user answers.
135
+ *
136
+ * @returns An object with correct count, total gradable, and percentage.
137
+ */
138
+ export function scoreAssessment(
139
+ questions: QuestionData[],
140
+ answers: SessionAnswer[],
141
+ ): { correct: number; total: number; percentage: number } {
142
+ let correct = 0;
143
+ let gradable = 0;
144
+ for (const q of questions) {
145
+ const userAnswers = answers.filter((a) => a.uid === q.uid);
146
+ if (q.type === "scenario" && q.scenarioScoringMode !== "all_or_nothing") {
147
+ const subResults = scoreScenarioSubQuestions(q, userAnswers);
148
+ for (const [, result] of subResults) {
149
+ if (result !== null) {
150
+ gradable++;
151
+ if (result) correct++;
152
+ }
153
+ }
154
+ continue;
155
+ }
156
+ const result = scoreQuestion(q, userAnswers);
157
+ if (result !== null) {
158
+ gradable++;
159
+ if (result) correct++;
160
+ }
161
+ }
162
+ const total = gradable > 0 ? gradable : questions.length;
163
+ const percentage = total > 0 ? Math.round((correct / total) * 100) : 0;
164
+ return { correct, total, percentage };
165
+ }
166
+
167
+ /** Parse scenario answer UIDs (format "subQUid::originalAnswerUid") and group by sub-question. */
168
+ function groupScenarioAnswers(
169
+ userAnswers: SessionAnswer[],
170
+ ): Map<string, SessionAnswer[]> {
171
+ const map = new Map<string, SessionAnswer[]>();
172
+ for (const ua of userAnswers) {
173
+ const sepIdx = ua.answerUid.indexOf("::");
174
+ if (sepIdx === -1) continue;
175
+ const subQUid = ua.answerUid.slice(0, sepIdx);
176
+ const originalAnswerUid = ua.answerUid.slice(sepIdx + 2);
177
+ const list = map.get(subQUid) ?? [];
178
+ list.push({ ...ua, answerUid: originalAnswerUid });
179
+ map.set(subQUid, list);
180
+ }
181
+ return map;
182
+ }
183
+
184
+ /**
185
+ * Scores individual sub-questions within a scenario.
186
+ * Returns a Map of sub-question UID to score (`true`/`false`/`null`).
187
+ */
188
+ export function scoreScenarioSubQuestions(
189
+ question: QuestionData,
190
+ userAnswers: SessionAnswer[],
191
+ ): Map<string, boolean | null> {
192
+ const results = new Map<string, boolean | null>();
193
+ const subQuestions = question.scenarioQuestions ?? [];
194
+ const grouped = groupScenarioAnswers(userAnswers);
195
+
196
+ for (const sq of subQuestions) {
197
+ const sqAnswers = grouped.get(sq.uid) ?? [];
198
+ results.set(sq.uid, scoreQuestion(sq, sqAnswers));
199
+ }
200
+ return results;
201
+ }
@@ -0,0 +1,259 @@
1
+ import { useState, useMemo, useRef } from "react";
2
+ import { debounce } from "../utils/debounce";
3
+ import { Input } from "../ui/input";
4
+ import {
5
+ Table,
6
+ TableHeader,
7
+ TableBody,
8
+ TableFooter,
9
+ TableRow,
10
+ TableHead,
11
+ TableCell,
12
+ } from "../ui/table";
13
+ import { Alert, AlertDescription } from "../ui/alert";
14
+ import { cn } from "../lib/utils";
15
+ import type {
16
+ QuestionProps,
17
+ SpreadsheetColumn,
18
+ SpreadsheetCell as SpreadsheetCellType,
19
+ } from "./types";
20
+
21
+ function getCellStatus(
22
+ cell: SpreadsheetCellType,
23
+ col: SpreadsheetColumn,
24
+ userValue: string,
25
+ ): "correct" | "incorrect" | "empty" | null {
26
+ if (cell.locked || !cell.correctAnswer) return null;
27
+ if (!userValue || userValue.trim() === "") return "empty";
28
+
29
+ if (col.type === "numeric" || col.type === "currency") {
30
+ const user = parseFloat(userValue.replace(/,/g, ""));
31
+ const correct = parseFloat(cell.correctAnswer);
32
+ if (isNaN(user) || isNaN(correct)) return "incorrect";
33
+ const tol = cell.tolerance ?? 0;
34
+ return Math.abs(user - correct) <= tol ? "correct" : "incorrect";
35
+ }
36
+
37
+ return userValue.trim().toLowerCase() === cell.correctAnswer.trim().toLowerCase()
38
+ ? "correct"
39
+ : "incorrect";
40
+ }
41
+
42
+ /**
43
+ * Spreadsheet renders a grid-based question for accounting and tabular data entry.
44
+ * Students fill in editable cells while locked cells display pre-filled values.
45
+ * Each cell is graded independently with support for numeric tolerance.
46
+ *
47
+ * @example
48
+ * <Spreadsheet
49
+ * question={{
50
+ * uid: "q1",
51
+ * type: "spreadsheet",
52
+ * content: "Complete the trial balance:",
53
+ * spreadsheetColumns: [
54
+ * { uid: "col-acct", label: "Account", type: "text", width: 14 },
55
+ * { uid: "col-dr", label: "Debit ($)", type: "currency", width: 9 },
56
+ * { uid: "col-cr", label: "Credit ($)", type: "currency", width: 9 },
57
+ * ],
58
+ * spreadsheetRows: [
59
+ * { uid: "r1", cells: [
60
+ * { uid: "r1-acct", columnUid: "col-acct", locked: true, value: "Cash" },
61
+ * { uid: "r1-dr", columnUid: "col-dr", locked: false, correctAnswer: "5000" },
62
+ * { uid: "r1-cr", columnUid: "col-cr", locked: true, value: "" },
63
+ * ]},
64
+ * ],
65
+ * }}
66
+ * onAnswer={(answers) => handleAnswer(answers)}
67
+ * />
68
+ */
69
+ export const Spreadsheet = ({
70
+ question,
71
+ sessionAnswers,
72
+ onAnswer,
73
+ readOnly = false,
74
+ showCorrectAnswers = false,
75
+ disabled = false,
76
+ }: QuestionProps) => {
77
+ const columns = question.spreadsheetColumns ?? [];
78
+ const rows = question.spreadsheetRows ?? [];
79
+
80
+ const colMap = useMemo(() => {
81
+ const m = new Map<string, SpreadsheetColumn>();
82
+ for (const col of columns) m.set(col.uid, col);
83
+ return m;
84
+ }, [columns]);
85
+
86
+ const editableCellUids = useMemo(
87
+ () => rows.flatMap((r) => r.cells.filter((c) => !c.locked).map((c) => c.uid)),
88
+ [rows],
89
+ );
90
+
91
+ const hasRowLabels = useMemo(() => rows.some((r) => r.label != null), [rows]);
92
+ const bodyRows = useMemo(() => rows.filter((r) => !r.isTotals), [rows]);
93
+ const totalsRows = useMemo(() => rows.filter((r) => r.isTotals), [rows]);
94
+
95
+ const onAnswerRef = useRef(onAnswer);
96
+ onAnswerRef.current = onAnswer;
97
+
98
+ const debouncedEmit = useMemo(
99
+ () =>
100
+ debounce((next: Map<string, string>) => {
101
+ onAnswerRef.current?.(
102
+ [...next.entries()].map(([uid, content]) => ({ uid, content })),
103
+ );
104
+ }, 300),
105
+ [],
106
+ );
107
+
108
+ const [values, setValues] = useState<Map<string, string>>(() => {
109
+ const map = new Map<string, string>();
110
+ for (const sa of sessionAnswers ?? []) {
111
+ if (sa.content !== undefined) map.set(sa.answerUid, sa.content);
112
+ }
113
+ for (const row of rows) {
114
+ for (const cell of row.cells) {
115
+ if (!cell.locked && !map.has(cell.uid)) {
116
+ map.set(cell.uid, cell.value ?? "");
117
+ }
118
+ }
119
+ }
120
+ return map;
121
+ });
122
+
123
+ const handleCellChange = (cellUid: string, raw: string) => {
124
+ if (readOnly || disabled) return;
125
+ const next = new Map(values);
126
+ next.set(cellUid, raw);
127
+ setValues(next);
128
+ debouncedEmit(next);
129
+ };
130
+
131
+ const handleKeyDown = (cellUid: string, e: React.KeyboardEvent<HTMLInputElement>) => {
132
+ if (e.key !== "Tab") return;
133
+ const idx = editableCellUids.indexOf(cellUid);
134
+ const nextIdx = e.shiftKey ? idx - 1 : idx + 1;
135
+ if (nextIdx < 0 || nextIdx >= editableCellUids.length) return;
136
+ e.preventDefault();
137
+ const nextUid = editableCellUids[nextIdx];
138
+ const nextInput = document.querySelector<HTMLInputElement>(
139
+ `[data-cell-uid="${nextUid}"]`,
140
+ );
141
+ nextInput?.focus();
142
+ };
143
+
144
+ const renderCell = (cell: SpreadsheetCellType) => {
145
+ const col = colMap.get(cell.columnUid);
146
+ if (!col) return null;
147
+
148
+ const userValue = values.get(cell.uid) ?? "";
149
+ const status = showCorrectAnswers ? getCellStatus(cell, col, userValue) : null;
150
+
151
+ const statusClasses =
152
+ status === "correct"
153
+ ? "bg-success/10 border-l-2 border-l-success/50"
154
+ : status === "incorrect" || status === "empty"
155
+ ? "bg-destructive/10 border-l-2 border-l-destructive/50"
156
+ : "";
157
+
158
+ if (cell.locked) {
159
+ return (
160
+ <TableCell
161
+ key={cell.uid}
162
+ className={cn(
163
+ "px-2 py-1.5 text-sm text-muted-foreground bg-muted/30",
164
+ col.type !== "text" && "text-right tabular-nums",
165
+ )}
166
+ >
167
+ {cell.value ?? ""}
168
+ </TableCell>
169
+ );
170
+ }
171
+
172
+ return (
173
+ <TableCell
174
+ key={cell.uid}
175
+ className={cn("p-0 relative", statusClasses)}
176
+ >
177
+ <Input
178
+ type={col.type === "numeric" ? "number" : "text"}
179
+ inputMode={col.type === "currency" ? "decimal" : undefined}
180
+ step={col.type === "numeric" ? "any" : undefined}
181
+ value={userValue}
182
+ onChange={(e) => handleCellChange(cell.uid, e.target.value)}
183
+ onKeyDown={(e) => handleKeyDown(cell.uid, e)}
184
+ disabled={readOnly || disabled}
185
+ data-cell-uid={cell.uid}
186
+ placeholder={col.type === "text" ? "" : "0.00"}
187
+ className={cn(
188
+ "h-8 rounded-none border-0 shadow-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring",
189
+ col.type !== "text" && "text-right tabular-nums",
190
+ )}
191
+ />
192
+ {showCorrectAnswers && status !== "correct" && status !== null && cell.correctAnswer && (
193
+ <div className="px-2 pb-1 text-xs">
194
+ <span className="text-success">{cell.correctAnswer}</span>
195
+ {cell.formula && (
196
+ <span className="ml-1 text-muted-foreground/70">({cell.formula})</span>
197
+ )}
198
+ </div>
199
+ )}
200
+ </TableCell>
201
+ );
202
+ };
203
+
204
+ const renderRow = (row: (typeof rows)[number]) => (
205
+ <TableRow
206
+ key={row.uid}
207
+ className={cn(
208
+ row.isHeader && "bg-muted/50 font-semibold hover:bg-muted/50",
209
+ )}
210
+ >
211
+ {hasRowLabels && (
212
+ <TableCell className="px-2 py-1.5 font-medium text-sm text-muted-foreground whitespace-nowrap">
213
+ {row.label ?? ""}
214
+ </TableCell>
215
+ )}
216
+ {row.cells.map(renderCell)}
217
+ </TableRow>
218
+ );
219
+
220
+ return (
221
+ <div className="flex flex-col gap-3">
222
+ <div dangerouslySetInnerHTML={{ __html: question.content }} />
223
+
224
+ <div className="rounded-md border border-border overflow-auto">
225
+ <Table>
226
+ <TableHeader>
227
+ <TableRow className="hover:bg-transparent">
228
+ {hasRowLabels && <TableHead className="w-auto" />}
229
+ {columns.map((col) => (
230
+ <TableHead
231
+ key={col.uid}
232
+ style={{ width: col.width ? `${col.width}rem` : undefined }}
233
+ className={cn(
234
+ col.type !== "text" && "text-right",
235
+ )}
236
+ >
237
+ {col.label}
238
+ </TableHead>
239
+ ))}
240
+ </TableRow>
241
+ </TableHeader>
242
+ <TableBody>{bodyRows.map(renderRow)}</TableBody>
243
+ {totalsRows.length > 0 && (
244
+ <TableFooter>{totalsRows.map(renderRow)}</TableFooter>
245
+ )}
246
+ </Table>
247
+ </div>
248
+
249
+ {showCorrectAnswers && question.explanation && (
250
+ <Alert className="mt-1">
251
+ <AlertDescription>
252
+ <strong>Explanation:</strong>{" "}
253
+ <span dangerouslySetInnerHTML={{ __html: question.explanation }} />
254
+ </AlertDescription>
255
+ </Alert>
256
+ )}
257
+ </div>
258
+ );
259
+ };
@@ -1,4 +1,4 @@
1
- import { useState, useEffect } from "react";
1
+ import { useState, useMemo } from "react";
2
2
  import type { QuestionProps } from "./types";
3
3
  import { Alert, AlertDescription } from "../ui/alert";
4
4
  import { cn } from "../lib/utils";
@@ -20,10 +20,13 @@ export const TrueFalse = ({
20
20
  showCorrectAnswers = false,
21
21
  disabled = false,
22
22
  }: QuestionProps) => {
23
- const [selectedAnswer, setSelectedAnswer] = useState<string>("");
23
+ const [selectedAnswer, setSelectedAnswer] = useState<string>(
24
+ () => sessionAnswers?.[0]?.answerUid || "",
25
+ );
24
26
 
25
- const sortedAnswers = [...(question.answers || [])].sort(
26
- (a, b) => a.sequence - b.sequence,
27
+ const sortedAnswers = useMemo(
28
+ () => [...(question.answers || [])].sort((a, b) => a.sequence - b.sequence),
29
+ [question.answers],
27
30
  );
28
31
 
29
32
  const handleChange = (uid: string) => {
@@ -33,11 +36,6 @@ export const TrueFalse = ({
33
36
  onAnswer?.([{ uid }]);
34
37
  };
35
38
 
36
- useEffect(() => {
37
- const current = sessionAnswers?.[0]?.answerUid || "";
38
- setSelectedAnswer(current);
39
- }, [sessionAnswers]);
40
-
41
39
  const getAnswerClasses = (answerUid: string) => {
42
40
  if (!showCorrectAnswers) return "px-2";
43
41