question-bank-framework 0.1.0 → 0.1.1

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.
package/bin/qb.ts CHANGED
File without changes
package/core/config.tsx CHANGED
@@ -31,22 +31,8 @@ export type {
31
31
  BankSiteConfig,
32
32
  BaseQuestion,
33
33
  DefineBankInput,
34
- QuestionKind,
35
- QuestionOption,
36
34
  QuestionStatus,
37
35
  QuestionStatusMap,
38
- QuizAnswer,
39
36
  QuizSession,
40
37
  } from './types.ts';
41
- export {
42
- getCorrectIds,
43
- isAnswerCorrect,
44
- isAnswerProvided,
45
- isChoiceQuestion,
46
- questionKind,
47
- questionKindLabels,
48
- questionKinds,
49
- scoreAnswers,
50
- toSelectedIds,
51
- } from './types.ts';
52
38
  export { questionStatuses } from './types.ts';
package/core/index.ts CHANGED
@@ -1,22 +1,7 @@
1
1
  // Re-export the stable core: types + BankContext/useBank/defineBank.
2
2
  // Framework code (ejectable) lives under framework/.
3
3
 
4
- export {
5
- BankContext,
6
- BankProvider,
7
- useBank,
8
- defineBank,
9
- getCorrectIds,
10
- isAnswerCorrect,
11
- isAnswerProvided,
12
- isChoiceQuestion,
13
- questionKind,
14
- questionKindLabels,
15
- questionKinds,
16
- questionStatuses,
17
- scoreAnswers,
18
- toSelectedIds,
19
- } from './config.tsx';
4
+ export { BankContext, BankProvider, useBank, defineBank, questionStatuses } from './config.tsx';
20
5
  export type {
21
6
  Bank,
22
7
  BankFieldDef,
@@ -26,10 +11,7 @@ export type {
26
11
  BankSiteConfig,
27
12
  BaseQuestion,
28
13
  DefineBankInput,
29
- QuestionKind,
30
- QuestionOption,
31
14
  QuestionStatus,
32
15
  QuestionStatusMap,
33
- QuizAnswer,
34
16
  QuizSession,
35
17
  } from './config.tsx';
package/core/types.ts CHANGED
@@ -7,41 +7,11 @@ export const questionStatuses = ['unanswered', 'revisit', 'complete'] as const;
7
7
  export type QuestionStatus = (typeof questionStatuses)[number];
8
8
  export type QuestionStatusMap = Record<string, QuestionStatus>;
9
9
 
10
- // ---------------------------------------------------------------------------
11
- // Question kinds: written (free response) vs choice (auto-gradeable).
12
- // ---------------------------------------------------------------------------
13
-
14
- export type QuestionKind = 'written' | 'single' | 'multiple';
15
-
16
- export const questionKinds: readonly QuestionKind[] = ['written', 'single', 'multiple'];
17
-
18
- export const questionKindLabels: Record<QuestionKind, string> = {
19
- written: 'Written',
20
- single: 'Single choice',
21
- multiple: 'Multiple choice',
22
- };
23
-
24
- export interface QuestionOption {
25
- /** Stable within the question, e.g. "a", "b", "c". */
26
- id: string;
27
- /** Option body. Markdown supported. */
28
- text: string;
29
- }
30
-
31
- /** Written answers are free text; choice answers are selected option ids. */
32
- export type QuizAnswer = string | string[];
33
-
34
10
  export interface BaseQuestion {
35
11
  id: string;
36
12
  title: string;
37
13
  prompt: string;
38
14
  tags: string[];
39
- /** Defaults to 'written' when omitted. Choice kinds require options + correctIds. */
40
- kind?: QuestionKind;
41
- /** Choice options. Required for single/multiple, forbidden for written. */
42
- options?: QuestionOption[];
43
- /** Correct option ids. Single choice requires exactly one. */
44
- correctIds?: string[];
45
15
  // Subject-specific fields (e.g. year, semester, marks, source).
46
16
  [key: string]: unknown;
47
17
  }
@@ -50,7 +20,7 @@ export interface QuizSession {
50
20
  id: string;
51
21
  title: string;
52
22
  questionIds: string[];
53
- answers: Record<string, QuizAnswer>;
23
+ answers: Record<string, string>;
54
24
  currentQuestion: number;
55
25
  completed: boolean;
56
26
  createdAt: number;
@@ -133,124 +103,9 @@ export interface Bank extends DefineBankInput {
133
103
  }
134
104
 
135
105
  export function defineBank(input: DefineBankInput): Bank {
136
- for (const question of input.questions) {
137
- validateQuestion(question);
138
- }
139
106
  return {
140
107
  ...input,
141
108
  answers: input.answers ?? {},
142
109
  questionById: new Map(input.questions.map((q) => [q.id, q])),
143
110
  };
144
111
  }
145
-
146
- function validateQuestion(question: BaseQuestion): void {
147
- const kind: QuestionKind = question.kind ?? 'written';
148
- if (!questionKinds.includes(kind)) {
149
- throw new Error(`Question "${question.id}" has unknown kind "${question.kind}".`);
150
- }
151
- if (kind === 'written') {
152
- if (question.options !== undefined) {
153
- throw new Error(`Question "${question.id}" is written and must not define options.`);
154
- }
155
- if (question.correctIds !== undefined) {
156
- throw new Error(`Question "${question.id}" is written and must not define correctIds.`);
157
- }
158
- return;
159
- }
160
- const options = question.options ?? [];
161
- if (options.length < 2) {
162
- throw new Error(`Question "${question.id}" (${kind}) needs at least 2 options.`);
163
- }
164
- const seen = new Set<string>();
165
- for (const option of options) {
166
- if (!option.id || seen.has(option.id)) {
167
- throw new Error(`Question "${question.id}" has a duplicate or empty option id "${option.id}".`);
168
- }
169
- seen.add(option.id);
170
- }
171
- const correctIds = question.correctIds ?? [];
172
- if (kind === 'single' && correctIds.length !== 1) {
173
- throw new Error(`Question "${question.id}" (single) needs exactly one correctId.`);
174
- }
175
- if (kind === 'multiple' && correctIds.length < 1) {
176
- throw new Error(`Question "${question.id}" (multiple) needs at least one correctId.`);
177
- }
178
- for (const id of correctIds) {
179
- if (!seen.has(id)) {
180
- throw new Error(`Question "${question.id}" has unknown correctId "${id}".`);
181
- }
182
- }
183
- }
184
-
185
- // ---------------------------------------------------------------------------
186
- // Choice helpers. All UI + scoring goes through these so option storage
187
- // (ids) stays decoupled from presentation (letters, markdown).
188
- // ---------------------------------------------------------------------------
189
-
190
- /** Effective kind: questions without `kind` are written. */
191
- export function questionKind(question: BaseQuestion): QuestionKind {
192
- return question.kind ?? 'written';
193
- }
194
-
195
- export function isChoiceQuestion(question: BaseQuestion): boolean {
196
- const kind = questionKind(question);
197
- return kind === 'single' || kind === 'multiple';
198
- }
199
-
200
- /** Correct option ids. Empty for written questions. */
201
- export function getCorrectIds(question: BaseQuestion): string[] {
202
- return isChoiceQuestion(question) ? [...(question.correctIds ?? [])] : [];
203
- }
204
-
205
- /** Normalize a stored answer to selected option ids — tolerates legacy strings. */
206
- export function toSelectedIds(question: BaseQuestion, answer: QuizAnswer | undefined): string[] {
207
- if (answer === undefined) return [];
208
- const ids =
209
- typeof answer === 'string' ? (answer === '' ? [] : [answer]) : answer.filter((id) => typeof id === 'string');
210
- if (!isChoiceQuestion(question)) return ids;
211
- const valid = new Set((question.options ?? []).map((o) => o.id));
212
- return ids.filter((id) => valid.has(id));
213
- }
214
-
215
- /** Whether the user has provided an answer (non-blank text, or ≥1 selection). */
216
- export function isAnswerProvided(question: BaseQuestion, answer: QuizAnswer | undefined): boolean {
217
- if (answer === undefined) return false;
218
- if (typeof answer === 'string') return answer.trim().length > 0;
219
- if (!isChoiceQuestion(question)) return answer.some((id) => id.trim().length > 0);
220
- const valid = new Set((question.options ?? []).map((o) => o.id));
221
- return answer.some((id) => valid.has(id));
222
- }
223
-
224
- /**
225
- * Grade a choice answer. Returns null for written questions or when
226
- * unanswered. Single: exactly the correct id. Multiple: exact set match
227
- * (order-insensitive, no extras, no omissions).
228
- */
229
- export function isAnswerCorrect(question: BaseQuestion, answer: QuizAnswer | undefined): boolean | null {
230
- if (!isChoiceQuestion(question)) return null;
231
- if (!isAnswerProvided(question, answer)) return null;
232
- const selected = new Set(toSelectedIds(question, answer));
233
- const correct = new Set(getCorrectIds(question));
234
- if (selected.size !== correct.size) return false;
235
- for (const id of selected) {
236
- if (!correct.has(id)) return false;
237
- }
238
- return true;
239
- }
240
-
241
- /** Score a set of questions against answers: { correct, gradeable, answered }. */
242
- export function scoreAnswers(
243
- questions: readonly BaseQuestion[],
244
- answers: Record<string, QuizAnswer>,
245
- ): { correct: number; gradeable: number; answered: number } {
246
- let correct = 0;
247
- let gradeable = 0;
248
- let answered = 0;
249
- for (const question of questions) {
250
- if (isAnswerProvided(question, answers[question.id])) answered += 1;
251
- if (!isChoiceQuestion(question)) continue;
252
- gradeable += 1;
253
- if (isAnswerCorrect(question, answers[question.id]) === true) correct += 1;
254
- }
255
- return { correct, gradeable, answered };
256
- }
@@ -17,8 +17,7 @@ import {
17
17
  } from '@tanstack/react-table';
18
18
  import { Search } from 'lucide-react';
19
19
  import { useBank } from '../../core/config.tsx';
20
- import type { BaseQuestion, QuestionKind, QuestionStatus, QuestionStatusMap } from '../../core/types.ts';
21
- import { isChoiceQuestion, questionKind, questionKindLabels } from '../../core/types.ts';
20
+ import type { BaseQuestion, QuestionStatus, QuestionStatusMap } from '../../core/types.ts';
22
21
  import { Badge } from './ui/badge';
23
22
  import { Button } from './ui/button';
24
23
  import { Checkbox } from './ui/checkbox';
@@ -60,14 +59,6 @@ const questionStatusStyles: Record<QuestionStatus, string> = {
60
59
  complete: 'border-green-300 bg-green-50 text-green-800',
61
60
  };
62
61
 
63
- const questionKindStyles: Record<QuestionKind, string> = {
64
- written: 'border-gray-300 bg-gray-50 text-gray-700',
65
- single: 'border-sky-300 bg-sky-50 text-sky-900',
66
- multiple: 'border-violet-300 bg-violet-50 text-violet-900',
67
- };
68
-
69
- const optionLetter = (index: number): string => String.fromCharCode(65 + index);
70
-
71
62
  function QuestionPreviewPopover({ question }: { question: BaseQuestion }) {
72
63
  const [open, setOpen] = useState(false);
73
64
  const openTimeout = useRef<number | null>(null);
@@ -172,18 +163,6 @@ function QuestionPreviewPopover({ question }: { question: BaseQuestion }) {
172
163
  >
173
164
  <div className="px-4 py-3">
174
165
  <MarkdownContent content={question.prompt} />
175
- {isChoiceQuestion(question) && (
176
- <ol className="mt-3 space-y-1.5">
177
- {(question.options ?? []).map((option, index) => (
178
- <li key={option.id} className="flex items-start gap-2 text-sm text-gray-800">
179
- <span className="mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full border border-gray-300 bg-gray-50 text-[11px] font-bold text-gray-600">
180
- {optionLetter(index)}
181
- </span>
182
- <MarkdownContent content={option.text} className="min-w-0 flex-1 text-sm leading-6" />
183
- </li>
184
- ))}
185
- </ol>
186
- )}
187
166
  </div>
188
167
  </PopoverContent>
189
168
  </Popover>
@@ -358,21 +337,6 @@ export function QuestionBank() {
358
337
  </div>
359
338
  ),
360
339
  },
361
- {
362
- id: 'kind',
363
- accessorFn: (row) => questionKind(row),
364
- header: 'Type',
365
- cell: ({ row }) => {
366
- const kind = questionKind(row.original);
367
- const count = row.original.options?.length ?? 0;
368
- return (
369
- <Badge variant="outline" className={questionKindStyles[kind]} title={count > 0 ? `${count} options` : undefined}>
370
- {questionKindLabels[kind]}
371
- </Badge>
372
- );
373
- },
374
- size: 132,
375
- },
376
340
  ...customs,
377
341
  {
378
342
  id: 'status',
@@ -5,7 +5,6 @@ import { Link } from '@tanstack/react-router';
5
5
  import { Trash2 } from 'lucide-react';
6
6
  import { useBank } from '../../core/config.tsx';
7
7
  import type { QuizSession } from '../../core/types.ts';
8
- import { isAnswerProvided, scoreAnswers } from '../../core/types.ts';
9
8
  import { Button } from './ui/button';
10
9
  import { deleteQuiz, listQuizzes } from '../lib/quiz-storage.ts';
11
10
 
@@ -64,13 +63,7 @@ export function QuizList() {
64
63
  )}
65
64
  <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
66
65
  {quizzes.map((quiz) => {
67
- const questions = quiz.questionIds
68
- .map((id) => bank.questionById.get(id))
69
- .filter((question) => question !== undefined);
70
- const answeredCount = questions.filter((question) =>
71
- isAnswerProvided(question, quiz.answers[question.id]),
72
- ).length;
73
- const score = scoreAnswers(questions, quiz.answers);
66
+ const answeredCount = Object.values(quiz.answers).filter((answer) => answer.trim().length > 0).length;
74
67
  return (
75
68
  <div key={quiz.id} className="group relative">
76
69
  <Link
@@ -84,7 +77,6 @@ export function QuizList() {
84
77
  </p>
85
78
  <p className="mt-1 text-sm text-gray-600">
86
79
  {answeredCount} of {quiz.questionIds.length} answered
87
- {score.gradeable > 0 && ` • ${score.correct}/${score.gradeable} correct`}
88
80
  </p>
89
81
  </Link>
90
82
  <Button
@@ -4,26 +4,13 @@ import { useEffect, useMemo, useRef, useState } from 'react';
4
4
  import { Link, useParams } from '@tanstack/react-router';
5
5
  import { Check, Pencil, X } from 'lucide-react';
6
6
  import { useBank } from '../../core/config.tsx';
7
- import type { QuestionStatus, QuizAnswer, QuizSession } from '../../core/types.ts';
8
- import {
9
- getCorrectIds,
10
- isAnswerCorrect,
11
- isAnswerProvided,
12
- isChoiceQuestion,
13
- questionKind,
14
- questionKindLabels,
15
- scoreAnswers,
16
- toSelectedIds,
17
- } from '../../core/types.ts';
7
+ import type { QuestionStatus, QuizSession } from '../../core/types.ts';
18
8
  import { Badge } from './ui/badge';
19
9
  import { Button } from './ui/button';
20
10
  import { Input } from './ui/input';
21
11
  import { MarkdownContent } from './markdown-content';
22
12
  import { setQuestionStatus } from '../lib/question-status-storage.ts';
23
13
  import { getQuiz, saveQuizLocally, saveQuizProgress } from '../lib/quiz-storage.ts';
24
- import { cn } from '../lib/utils.ts';
25
-
26
- const optionLetter = (index: number): string => String.fromCharCode(65 + index);
27
14
 
28
15
  export function QuizPage() {
29
16
  const bank = useBank();
@@ -32,7 +19,6 @@ export function QuizPage() {
32
19
  const [loading, setLoading] = useState(true);
33
20
  const [saving, setSaving] = useState(false);
34
21
  const [shownAnswerId, setShownAnswerId] = useState<string | null>(null);
35
- const [checked, setChecked] = useState<Record<string, boolean>>({});
36
22
  const [editingTitle, setEditingTitle] = useState(false);
37
23
  const [titleDraft, setTitleDraft] = useState('');
38
24
  const titleInputRef = useRef<HTMLInputElement>(null);
@@ -47,7 +33,6 @@ export function QuizPage() {
47
33
  useEffect(() => {
48
34
  let active = true;
49
35
  setShownAnswerId(null);
50
- setChecked({});
51
36
  getQuiz(bank.namespace, quizId).then((quiz) => {
52
37
  if (active) setSession(quiz);
53
38
  }).finally(() => {
@@ -87,39 +72,13 @@ export function QuizPage() {
87
72
  setSession((current) => (current ? { ...current, currentQuestion: index, completed: false } : current));
88
73
  };
89
74
 
90
- const updateWrittenAnswer = (value: string) => {
75
+ const updateAnswer = (value: string) => {
91
76
  if (!activeQuestion) return;
92
77
  setSession((current) =>
93
78
  current ? { ...current, completed: false, answers: { ...current.answers, [activeQuestion.id]: value } } : current,
94
79
  );
95
80
  };
96
81
 
97
- const setChoiceAnswer = (questionId: string, value: string[]) => {
98
- setChecked((current) => (current[questionId] ? { ...current, [questionId]: false } : current));
99
- setSession((current) =>
100
- current ? { ...current, completed: false, answers: { ...current.answers, [questionId]: value } } : current,
101
- );
102
- };
103
-
104
- const toggleOption = (questionId: string, optionId: string, single: boolean) => {
105
- if (!activeQuestion) return;
106
- const current = toSelectedIds(activeQuestion, session?.answers[questionId]);
107
- if (single) {
108
- setChoiceAnswer(questionId, current[0] === optionId ? [] : [optionId]);
109
- } else {
110
- setChoiceAnswer(
111
- questionId,
112
- current.includes(optionId) ? current.filter((id) => id !== optionId) : [...current, optionId],
113
- );
114
- }
115
- };
116
-
117
- const checkAnswer = () => {
118
- if (!activeQuestion) return;
119
- setChecked((current) => ({ ...current, [activeQuestion.id]: true }));
120
- if (bank.answers[activeQuestion.id]) setShownAnswerId(activeQuestion.id);
121
- };
122
-
123
82
  const updateQuestionStatus = (status: QuestionStatus) => {
124
83
  if (!activeQuestion) return;
125
84
  void setQuestionStatus(bank.namespace, activeQuestion.id, status);
@@ -162,17 +121,9 @@ export function QuizPage() {
162
121
  );
163
122
  }
164
123
 
165
- const answer: QuizAnswer | undefined = session.answers[activeQuestion.id];
124
+ const answer = session.answers[activeQuestion.id] ?? '';
166
125
  const suggestedAnswer = bank.answers[activeQuestion.id];
167
126
  const isSuggestedAnswerShown = shownAnswerId === activeQuestion.id;
168
- const choice = isChoiceQuestion(activeQuestion);
169
- const kind = questionKind(activeQuestion);
170
- const selectedIds = toSelectedIds(activeQuestion, answer);
171
- const hasSelection = isAnswerProvided(activeQuestion, answer);
172
- const isChecked = checked[activeQuestion.id] === true;
173
- const correctness = isChecked ? isAnswerCorrect(activeQuestion, answer) : null;
174
- const correctIds = getCorrectIds(activeQuestion);
175
- const score = scoreAnswers(quizQuestions, session.answers);
176
127
 
177
128
  return (
178
129
  <div className="mx-auto grid max-w-3xl gap-6">
@@ -217,66 +168,12 @@ export function QuizPage() {
217
168
  <span className="text-sm text-gray-600">{saving ? 'Saving...' : 'Saved'}</span>
218
169
  </div>
219
170
 
220
- <div className="flex flex-wrap items-center gap-3">
221
- <p className="m-0 text-base font-semibold text-gray-900">
222
- Question {activeIndex + 1} of {quizQuestions.length}
223
- </p>
224
- {score.gradeable > 0 && (
225
- <Badge variant="secondary" aria-label={`Quiz score: ${score.correct} of ${score.gradeable} correct`}>
226
- {score.correct}/{score.gradeable} correct
227
- </Badge>
228
- )}
229
- {session.completed && (
230
- <Badge variant="outline" className="border-green-300 bg-green-50 text-green-800">
231
- Quiz complete
232
- </Badge>
233
- )}
234
- </div>
235
-
236
- {session.completed && score.gradeable > 0 && (
237
- <section aria-label="Quiz results" className="rounded-xl border border-gray-200 bg-gray-50 p-5">
238
- <h2 className="m-0 text-lg font-semibold text-gray-900">
239
- You scored {score.correct} of {score.gradeable}
240
- </h2>
241
- <ol className="mt-3 grid gap-1.5">
242
- {quizQuestions.map((question, index) => {
243
- const result = isAnswerCorrect(question, session.answers[question.id]);
244
- return (
245
- <li key={question.id}>
246
- <button
247
- type="button"
248
- onClick={() => goToQuestion(index)}
249
- className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm hover:bg-gray-100"
250
- aria-label={`Review question ${index + 1}: ${question.title} — ${result === null ? 'written' : result ? 'correct' : 'incorrect'}`}
251
- >
252
- <span
253
- aria-hidden="true"
254
- className={cn(
255
- 'flex size-5 shrink-0 items-center justify-center rounded-full text-[11px] font-bold',
256
- result === true && 'bg-green-600 text-white',
257
- result === false && 'bg-red-600 text-white',
258
- result === null && 'bg-gray-200 text-gray-700',
259
- )}
260
- >
261
- {result === true ? <Check className="size-3" /> : result === false ? <X className="size-3" /> : index + 1}
262
- </span>
263
- <span className="min-w-0 flex-1 truncate text-gray-900">{question.title}</span>
264
- <span className="shrink-0 text-xs text-gray-500">
265
- {result === null ? 'Written' : result ? 'Correct' : 'Incorrect'}
266
- </span>
267
- </button>
268
- </li>
269
- );
270
- })}
271
- </ol>
272
- </section>
273
- )}
171
+ <p className="text-base font-semibold text-gray-900">
172
+ Question {activeIndex + 1} of {quizQuestions.length}
173
+ </p>
274
174
 
275
175
  <section className="rounded-xl border border-gray-200 bg-white p-5">
276
- <div className="flex flex-wrap items-center gap-2">
277
- <h2 className="m-0 flex-1 text-xl font-semibold text-gray-900">{activeQuestion.title}</h2>
278
- <Badge variant="outline">{questionKindLabels[kind]}</Badge>
279
- </div>
176
+ <h2 className="m-0 text-xl font-semibold text-gray-900">{activeQuestion.title}</h2>
280
177
  <p className="mt-1 font-mono text-xs text-gray-500">{activeQuestion.id}</p>
281
178
  {activeQuestion.tags.length > 0 && (
282
179
  <div className="mt-2 flex flex-wrap gap-1">
@@ -289,134 +186,32 @@ export function QuizPage() {
289
186
  )}
290
187
  <MarkdownContent content={activeQuestion.prompt} className="mt-4 text-base leading-relaxed text-gray-800" />
291
188
 
292
- {choice ? (
293
- <div className="mt-6">
294
- <p id="quiz-choices-label" className="text-sm font-semibold text-gray-900">
295
- {kind === 'single' ? 'Choose one answer' : 'Choose all that apply'}
296
- </p>
297
- <div role={kind === 'single' ? 'radiogroup' : 'group'} aria-labelledby="quiz-choices-label" className="mt-2 grid gap-2">
298
- {(activeQuestion.options ?? []).map((option, index) => {
299
- const selected = selectedIds.includes(option.id);
300
- const isCorrectOption = correctIds.includes(option.id);
301
- const showCorrectness = isChecked;
302
- return (
303
- <label
304
- key={option.id}
305
- className={cn(
306
- 'flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition-colors',
307
- !showCorrectness && selected && 'border-gray-900 bg-gray-50',
308
- !showCorrectness && !selected && 'border-gray-300 bg-white hover:border-gray-500',
309
- showCorrectness && isCorrectOption && 'border-green-600 bg-green-50',
310
- showCorrectness && !isCorrectOption && selected && 'border-red-600 bg-red-50',
311
- showCorrectness && !isCorrectOption && !selected && 'border-gray-200 bg-white opacity-70',
312
- )}
313
- >
314
- <input
315
- type={kind === 'single' ? 'radio' : 'checkbox'}
316
- name={`quiz-choice-${activeQuestion.id}`}
317
- checked={selected}
318
- onChange={() => toggleOption(activeQuestion.id, option.id, kind === 'single')}
319
- className="mt-1 size-4 shrink-0 accent-gray-900"
320
- aria-label={`Option ${optionLetter(index)}`}
321
- />
322
- <span
323
- aria-hidden="true"
324
- className={cn(
325
- 'mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full border text-xs font-bold',
326
- showCorrectness && isCorrectOption
327
- ? 'border-green-600 bg-green-600 text-white'
328
- : showCorrectness && selected
329
- ? 'border-red-600 bg-red-600 text-white'
330
- : selected
331
- ? 'border-gray-900 bg-gray-900 text-white'
332
- : 'border-gray-300 bg-white text-gray-600',
333
- )}
334
- >
335
- {optionLetter(index)}
336
- </span>
337
- <MarkdownContent content={option.text} className="min-w-0 flex-1 text-base leading-7" />
338
- {showCorrectness && isCorrectOption && (
339
- <Badge variant="outline" className="shrink-0 border-green-600 bg-white text-green-800">
340
- Correct
341
- </Badge>
342
- )}
343
- {showCorrectness && !isCorrectOption && selected && (
344
- <Badge variant="outline" className="shrink-0 border-red-600 bg-white text-red-800">
345
- Yours
346
- </Badge>
347
- )}
348
- </label>
349
- );
350
- })}
351
- </div>
352
-
353
- {isChecked && correctness !== null && (
354
- <div
355
- role="status"
356
- className={cn(
357
- 'mt-4 rounded-lg border p-4 text-sm font-medium',
358
- correctness ? 'border-green-300 bg-green-50 text-green-900' : 'border-red-300 bg-red-50 text-red-900',
359
- )}
360
- >
361
- {correctness
362
- ? 'Correct — nice work.'
363
- : `Not quite. Correct answer${correctIds.length > 1 ? 's' : ''}: ${correctIds
364
- .map((id) => {
365
- const index = (activeQuestion.options ?? []).findIndex((o) => o.id === id);
366
- return index >= 0 ? optionLetter(index) : id;
367
- })
368
- .join(', ')}`}
369
- </div>
370
- )}
371
-
372
- <div className="mt-4 flex flex-wrap gap-2">
373
- <Button onClick={checkAnswer} disabled={!hasSelection} variant={isChecked ? 'outline' : 'default'}>
374
- {isChecked ? 'Checked' : 'Check answer'}
375
- </Button>
376
- {suggestedAnswer && (
377
- <Button
378
- variant="outline"
379
- aria-expanded={isSuggestedAnswerShown}
380
- aria-controls="suggested-answer"
381
- onClick={() => setShownAnswerId(isSuggestedAnswerShown ? null : activeQuestion.id)}
382
- >
383
- {isSuggestedAnswerShown ? 'Hide explanation' : 'Show explanation'}
384
- </Button>
385
- )}
386
- </div>
387
- </div>
388
- ) : (
389
- <div className="mt-6">
390
- <label htmlFor="quiz-answer" className="text-sm font-semibold text-gray-900">
391
- Your answer
392
- </label>
393
- <textarea
394
- id="quiz-answer"
395
- value={typeof answer === 'string' ? answer : ''}
396
- onChange={(event) => updateWrittenAnswer(event.target.value)}
397
- placeholder="Write your working or notes here..."
398
- className="mt-2 block min-h-40 w-full resize-y rounded-lg border border-gray-300 bg-white p-3 text-base leading-relaxed text-gray-900 outline-none placeholder:text-gray-400"
399
- />
400
- </div>
401
- )}
189
+ <div className="mt-6">
190
+ <label htmlFor="quiz-answer" className="text-sm font-semibold text-gray-900">
191
+ Your answer
192
+ </label>
193
+ <textarea
194
+ id="quiz-answer"
195
+ value={answer}
196
+ onChange={(event) => updateAnswer(event.target.value)}
197
+ placeholder="Write your working or notes here..."
198
+ className="mt-2 block min-h-40 w-full resize-y rounded-lg border border-gray-300 bg-white p-3 text-base leading-relaxed text-gray-900 outline-none placeholder:text-gray-400"
199
+ />
200
+ </div>
402
201
 
403
- {suggestedAnswer && (choice ? isSuggestedAnswerShown : true) && (
202
+ {suggestedAnswer && (
404
203
  <div className="mt-5">
405
- {!choice && (
406
- <Button
407
- variant="outline"
408
- aria-expanded={isSuggestedAnswerShown}
409
- aria-controls="suggested-answer"
410
- onClick={() => setShownAnswerId(isSuggestedAnswerShown ? null : activeQuestion.id)}
411
- >
412
- {isSuggestedAnswerShown ? 'Hide answer' : 'Show answer'}
413
- </Button>
414
- )}
204
+ <Button
205
+ variant="outline"
206
+ aria-expanded={isSuggestedAnswerShown}
207
+ aria-controls="suggested-answer"
208
+ onClick={() => setShownAnswerId(isSuggestedAnswerShown ? null : activeQuestion.id)}
209
+ >
210
+ {isSuggestedAnswerShown ? 'Hide answer' : 'Show answer'}
211
+ </Button>
415
212
  {isSuggestedAnswerShown && (
416
213
  <section id="suggested-answer" className="mt-4 rounded-lg border border-gray-200 bg-gray-50 p-4">
417
- <h3 className="m-0 text-base font-semibold text-gray-900">
418
- {choice ? 'Explanation' : 'Suggested answer'}
419
- </h3>
214
+ <h3 className="m-0 text-base font-semibold text-gray-900">Suggested answer</h3>
420
215
  <MarkdownContent content={suggestedAnswer} className="mt-3 text-sm leading-relaxed text-gray-800" />
421
216
  </section>
422
217
  )}
@@ -424,18 +219,9 @@ export function QuizPage() {
424
219
  )}
425
220
 
426
221
  <div className="mt-6 flex flex-wrap items-center justify-between gap-3">
427
- <div className="flex gap-2">
428
- <Button variant="outline" onClick={() => goToQuestion(Math.max(activeIndex - 1, 0))} disabled={activeIndex === 0}>
429
- Previous
430
- </Button>
431
- <Button
432
- variant="outline"
433
- onClick={() => goToQuestion(Math.min(activeIndex + 1, quizQuestions.length - 1))}
434
- disabled={activeIndex === quizQuestions.length - 1}
435
- >
436
- Next
437
- </Button>
438
- </div>
222
+ <Button variant="outline" onClick={() => goToQuestion(Math.max(activeIndex - 1, 0))} disabled={activeIndex === 0}>
223
+ Previous
224
+ </Button>
439
225
  <div className="ml-auto flex flex-wrap items-center justify-end gap-2">
440
226
  <Button variant="outline" onClick={() => updateQuestionStatus('unanswered')} disabled={session.completed}>
441
227
  Skip
@@ -40,7 +40,7 @@ export const saveProgress = mutation({
40
40
  args: {
41
41
  quizId: v.id('quizzes'),
42
42
  title: v.string(),
43
- answers: v.record(v.string(), v.union(v.string(), v.array(v.string()))),
43
+ answers: v.record(v.string(), v.string()),
44
44
  currentQuestion: v.number(),
45
45
  completed: v.boolean(),
46
46
  },
@@ -5,7 +5,7 @@ export default defineSchema({
5
5
  quizzes: defineTable({
6
6
  title: v.string(),
7
7
  questionIds: v.array(v.string()),
8
- answers: v.record(v.string(), v.union(v.string(), v.array(v.string()))),
8
+ answers: v.record(v.string(), v.string()),
9
9
  currentQuestion: v.number(),
10
10
  completed: v.boolean(),
11
11
  createdAt: v.number(),
@@ -2,13 +2,13 @@
2
2
  // Framework-owned: eject with `qb reveal framework/lib/convex-api.ts`.
3
3
 
4
4
  import { makeFunctionReference } from 'convex/server';
5
- import type { QuestionStatus, QuizAnswer, QuizSession } from '../../core/types.ts';
5
+ import type { QuestionStatus, QuizSession } from '../../core/types.ts';
6
6
 
7
7
  type CreateQuizArgs = { title: string; questionIds: string[] };
8
8
  type ProgressArgs = {
9
9
  quizId: string;
10
10
  title: string;
11
- answers: Record<string, QuizAnswer>;
11
+ answers: Record<string, string>;
12
12
  currentQuestion: number;
13
13
  completed: boolean;
14
14
  };
@@ -3,7 +3,7 @@
3
3
  // by config-defined `BankFieldDef[]` plus the three built-ins.
4
4
  // Framework-owned: eject with `qb reveal framework/lib/question-filter.ts`.
5
5
 
6
- import { questionKind, questionKindLabels, questionKinds, questionStatuses, type BankFieldDef, type BankFilterOperatorId, type BaseQuestion, type QuestionStatus } from '../../core/types.ts';
6
+ import { questionStatuses, type BankFieldDef, type BankFilterOperatorId, type BaseQuestion, type QuestionStatus } from '../../core/types.ts';
7
7
 
8
8
  export type FilterMatch = 'and' | 'or';
9
9
 
@@ -101,13 +101,6 @@ function builtinFields(): FilterField[] {
101
101
  { id: 'title', label: 'Question', type: 'text', operators: textOperators, placeholder: 'Type a value…' },
102
102
  { id: 'id', label: 'ID', type: 'text', operators: textOperators, placeholder: 'Type a value…' },
103
103
  { id: 'tags', label: 'Tags', type: 'multiselect', operators: multiSelectOperators },
104
- {
105
- id: 'kind',
106
- label: 'Type',
107
- type: 'select',
108
- operators: selectOperators,
109
- options: questionKinds.map((kind) => ({ value: kind, label: questionKindLabels[kind] })),
110
- },
111
104
  {
112
105
  id: 'status',
113
106
  label: 'Status',
@@ -328,7 +321,6 @@ export function matchesRule(
328
321
  ): boolean {
329
322
  const type = filterFieldType(fields, rule.field);
330
323
  if (rule.field === 'status') return matchSelect(status, rule);
331
- if (rule.field === 'kind') return matchSelect(questionKind(question), rule);
332
324
  const def = fieldDefs.find((d) => d.id === rule.field);
333
325
  const raw = resolveFieldValue(def, rule.field, question);
334
326
  if (type === 'text') return matchText(toText(raw), rule);
@@ -352,7 +344,7 @@ export function matchesFilters(
352
344
  : active.some((rule) => matchesRule(rule, fields, fieldDefs, question, status));
353
345
  }
354
346
 
355
- /** Free-text search across id/title/prompt/tags/options plus `searchable` custom fields. */
347
+ /** Free-text search across id/title/prompt/tags plus `searchable` custom fields. */
356
348
  export function matchesSearch(
357
349
  question: BaseQuestion,
358
350
  query: string,
@@ -360,13 +352,7 @@ export function matchesSearch(
360
352
  ): boolean {
361
353
  const q = query.trim().toLowerCase();
362
354
  if (!q) return true;
363
- const parts = [
364
- question.id,
365
- question.title,
366
- question.prompt,
367
- question.tags.join(' '),
368
- (question.options ?? []).map((o) => o.text).join(' '),
369
- ];
355
+ const parts = [question.id, question.title, question.prompt, question.tags.join(' ')];
370
356
  for (const def of fieldDefs) {
371
357
  if (!def.searchable) continue;
372
358
  const raw = def.getValue(question);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "question-bank-framework",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Framework-style question bank: TanStack Start + TanStack Table grid, quizzes, and filter DSL. Bring your own bank.config.ts + content/. Eject any framework/ file with `qb reveal` (Vike-style).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -25,15 +25,6 @@
25
25
  "bin",
26
26
  "README.md"
27
27
  ],
28
- "scripts": {
29
- "typecheck": "tsc --noEmit",
30
- "changeset": "changeset",
31
- "version-packages": "changeset version",
32
- "release": "pnpm build:bin && changeset publish",
33
- "reveal": "node ./bin/qb.js reveal",
34
- "template:check": "node ./bin/qb.js check-template",
35
- "build:bin": "tsc bin/qb.ts --ignoreConfig --outDir bin --target es2022 --module nodenext --moduleResolution nodenext --strict --skipLibCheck --types node && chmod +x bin/qb.js"
36
- },
37
28
  "peerDependencies": {
38
29
  "@tanstack/react-router": "^1.170.0",
39
30
  "react": "^19.2.0",
@@ -64,5 +55,14 @@
64
55
  "@types/react-dom": "^19.2.0",
65
56
  "typescript": "^6.0.2",
66
57
  "vite": "^8.3.5"
58
+ },
59
+ "scripts": {
60
+ "typecheck": "tsc --noEmit",
61
+ "changeset": "changeset",
62
+ "version-packages": "changeset version",
63
+ "release": "pnpm build:bin && changeset publish",
64
+ "reveal": "node ./bin/qb.js reveal",
65
+ "template:check": "node ./bin/qb.js check-template",
66
+ "build:bin": "tsc bin/qb.ts --ignoreConfig --outDir bin --target es2022 --module nodenext --moduleResolution nodenext --strict --skipLibCheck --types node && chmod +x bin/qb.js"
67
67
  }
68
- }
68
+ }
@@ -3,6 +3,4 @@
3
3
 
4
4
  export const answers: Record<string, string> = {
5
5
  '2019-s1-q01': String.raw`**(a)** Not well-formed: several sibling top-level elements, so no single root.`,
6
- '2019-s1-mc01': String.raw`Only **B** has a single root element with matching open and close tags. A never closes, C closes in the wrong order, and D is not an element at all.`,
7
- '2019-s1-mc02': String.raw`Remove or correct physically impossible values (**A**), always investigate before discarding (**C**), and document any imputation (**D**). Deleting everything above the mean (**B**) discards roughly half of legitimate data.`,
8
6
  };
@@ -1,7 +1,6 @@
1
1
  [
2
2
  {
3
3
  "id": "2019-s1-q01",
4
- "kind": "written",
5
4
  "year": 2019,
6
5
  "semester": "S1",
7
6
  "number": 1,
@@ -13,7 +12,6 @@
13
12
  },
14
13
  {
15
14
  "id": "2019-s1-q02",
16
- "kind": "written",
17
15
  "year": 2019,
18
16
  "semester": "S1",
19
17
  "number": 2,
@@ -25,7 +23,6 @@
25
23
  },
26
24
  {
27
25
  "id": "ioa-001",
28
- "kind": "written",
29
26
  "year": 2026,
30
27
  "semester": "S2",
31
28
  "number": 1,
@@ -34,43 +31,5 @@
34
31
  "prompt": "What is your research question, and what result would count as evidence?",
35
32
  "source": "assignment-ioa",
36
33
  "tags": ["Assignment questions", "Justification"]
37
- },
38
- {
39
- "id": "2019-s1-mc01",
40
- "kind": "single",
41
- "year": 2019,
42
- "semester": "S1",
43
- "number": 3,
44
- "marks": 1,
45
- "title": "Well-formed XML",
46
- "prompt": "Which of the following XML documents is well-formed?",
47
- "options": [
48
- { "id": "a", "text": "`<subject>COMP20008<subject>`" },
49
- { "id": "b", "text": "`<subject>COMP20008</subject>`" },
50
- { "id": "c", "text": "`<subject><topic>COMP20008</subject></topic>`" },
51
- { "id": "d", "text": "`COMP20008`" }
52
- ],
53
- "correctIds": ["b"],
54
- "source": "2019-s1.md",
55
- "tags": ["Data fundamentals", "Data, formats & databases"]
56
- },
57
- {
58
- "id": "2019-s1-mc02",
59
- "kind": "multiple",
60
- "year": 2019,
61
- "semester": "S1",
62
- "number": 4,
63
- "marks": 2,
64
- "title": "Outlier handling",
65
- "prompt": "A temperature sensor reports occasional extreme spikes. Which of the following are reasonable outlier-handling steps? **Select all that apply.**",
66
- "options": [
67
- { "id": "a", "text": "Remove spikes that are physically impossible for the sensor" },
68
- { "id": "b", "text": "Delete every reading above the mean" },
69
- { "id": "c", "text": "Flag spikes and investigate whether they coincide with known events" },
70
- { "id": "d", "text": "Replace spikes with interpolated values and document the change" }
71
- ],
72
- "correctIds": ["a", "c", "d"],
73
- "source": "2019-s1.md",
74
- "tags": ["Data preparation", "Data quality & preparation"]
75
34
  }
76
35
  ]