question-bank-framework 0.1.0 → 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.
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
@@ -1,56 +1,26 @@
1
1
  // Core question + bank types. Framework-owned, never ejected.
2
- // A bank's question shape is config-defined: every question has the four
3
- // core fields, plus arbitrary subject-specific metadata (year, marks, …).
2
+ // Every question has the core fields below; subjects extend the interface
3
+ // with their own metadata (year, marks, …) and the bank stays generic over
4
+ // it — no index signatures, no `unknown`, no casts in subject code.
4
5
 
5
6
  export const questionStatuses = ['unanswered', 'revisit', 'complete'] as const;
6
7
 
7
8
  export type QuestionStatus = (typeof questionStatuses)[number];
8
9
  export type QuestionStatusMap = Record<string, QuestionStatus>;
9
10
 
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
11
  export interface BaseQuestion {
35
12
  id: string;
36
13
  title: string;
37
14
  prompt: string;
38
15
  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
- // Subject-specific fields (e.g. year, semester, marks, source).
46
- [key: string]: unknown;
16
+ // Subjects extend this interface with their own fields; see template/bank.config.ts.
47
17
  }
48
18
 
49
19
  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;
@@ -84,13 +54,19 @@ export interface BankFieldOption {
84
54
  label: string;
85
55
  }
86
56
 
87
- export interface BankFieldDef {
57
+ export interface BankFieldDef<Q extends BaseQuestion = BaseQuestion> {
88
58
  /** Stable field id. Reserved: id, title, tags, status. */
89
59
  id: string;
90
60
  label: string;
91
61
  type: BankFieldType;
92
- /** Extract the raw value from a question. */
93
- getValue: (question: BaseQuestion) => string | number | null | readonly string[];
62
+ /**
63
+ * Extract the raw value from a question. Q is inferred from the bank's
64
+ * questions. Method syntax (not a function property) keeps subject field
65
+ * defs assignable to the stored bank with zero casts — standard
66
+ * bivariant-method assignability, like Map/Set methods. Sound here
67
+ * because fields are only ever called with their own bank's questions.
68
+ */
69
+ getValue(question: Q): string | number | null | readonly string[];
94
70
  /**
95
71
  * Fixed options for select/multiselect. When omitted, options are derived
96
72
  * from distinct values across the bank's questions (sorted).
@@ -112,13 +88,13 @@ export interface BankSiteConfig {
112
88
  description?: string;
113
89
  }
114
90
 
115
- export interface DefineBankInput {
91
+ export interface DefineBankInput<Q extends BaseQuestion = BaseQuestion> {
116
92
  site: BankSiteConfig;
117
93
  /** Namespaces localStorage keys + Convex lookups. e.g. "comp20008". */
118
94
  namespace: string;
119
95
  /** Subject-specific schema. Built-ins (id/title/tags/status) are automatic. */
120
- fields: BankFieldDef[];
121
- questions: BaseQuestion[];
96
+ fields: BankFieldDef<Q>[];
97
+ questions: Q[];
122
98
  /** Suggested answers keyed by question id. Missing entries hide "Show answer". */
123
99
  answers?: Record<string, string>;
124
100
  /** Default sort for the grid, e.g. [{ id: "exam", desc: true }]. */
@@ -127,130 +103,20 @@ export interface DefineBankInput {
127
103
  publicPath?: string;
128
104
  }
129
105
 
130
- export interface Bank extends DefineBankInput {
106
+ export interface Bank extends DefineBankInput<BaseQuestion> {
131
107
  answers: Record<string, string>;
132
108
  questionById: Map<string, BaseQuestion>;
133
109
  }
134
110
 
135
- export function defineBank(input: DefineBankInput): Bank {
136
- for (const question of input.questions) {
137
- validateQuestion(question);
138
- }
111
+ /**
112
+ * Build a bank. Q is inferred from `questions`, so each field's getValue is
113
+ * checked against the subject's question type — accessors use plain property
114
+ * access, no casts or coercion helpers.
115
+ */
116
+ export function defineBank<Q extends BaseQuestion>(input: DefineBankInput<Q>): Bank {
139
117
  return {
140
118
  ...input,
141
119
  answers: input.answers ?? {},
142
120
  questionById: new Map(input.questions.map((q) => [q.id, q])),
143
121
  };
144
122
  }
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',
@@ -227,7 +220,9 @@ function resolveFieldValue(
227
220
  if (ruleField === 'title') return question.title;
228
221
  if (ruleField === 'id') return question.id;
229
222
  if (ruleField === 'tags') return question.tags;
230
- return (question[ruleField] as string | number | null | readonly string[]) ?? null;
223
+ // status is handled by matchesRule before reaching here. Any other id
224
+ // is stale (no def, not a built-in) — match nothing.
225
+ return null;
231
226
  }
232
227
  return def.getValue(question);
233
228
  }
@@ -328,7 +323,6 @@ export function matchesRule(
328
323
  ): boolean {
329
324
  const type = filterFieldType(fields, rule.field);
330
325
  if (rule.field === 'status') return matchSelect(status, rule);
331
- if (rule.field === 'kind') return matchSelect(questionKind(question), rule);
332
326
  const def = fieldDefs.find((d) => d.id === rule.field);
333
327
  const raw = resolveFieldValue(def, rule.field, question);
334
328
  if (type === 'text') return matchText(toText(raw), rule);
@@ -352,7 +346,7 @@ export function matchesFilters(
352
346
  : active.some((rule) => matchesRule(rule, fields, fieldDefs, question, status));
353
347
  }
354
348
 
355
- /** Free-text search across id/title/prompt/tags/options plus `searchable` custom fields. */
349
+ /** Free-text search across id/title/prompt/tags plus `searchable` custom fields. */
356
350
  export function matchesSearch(
357
351
  question: BaseQuestion,
358
352
  query: string,
@@ -360,13 +354,7 @@ export function matchesSearch(
360
354
  ): boolean {
361
355
  const q = query.trim().toLowerCase();
362
356
  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
- ];
357
+ const parts = [question.id, question.title, question.prompt, question.tags.join(' ')];
370
358
  for (const def of fieldDefs) {
371
359
  if (!def.searchable) continue;
372
360
  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.2.0",
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,26 +3,20 @@
3
3
  // config-defined — the framework never hardcodes subject fields.
4
4
 
5
5
  import { defineBank } from 'question-bank-framework';
6
+ import type { BaseQuestion } from 'question-bank-framework';
6
7
  import questionsJson from './content/questions.json' with { type: 'json' };
7
8
  import { answers } from './content/answers.ts';
8
- import type { BaseQuestion } from 'question-bank-framework';
9
-
10
- const questions = questionsJson as BaseQuestion[];
11
9
 
12
- const str = (q: BaseQuestion, key: string): string => {
13
- const v = q[key];
14
- return typeof v === 'string' || typeof v === 'number' ? String(v) : '';
15
- };
10
+ /** Subject question shape. Declare every field your accessors touch. */
11
+ export interface Comp20008Question extends BaseQuestion {
12
+ year: number;
13
+ semester: string;
14
+ number: number;
15
+ marks: number | null;
16
+ source: string;
17
+ }
16
18
 
17
- const num = (q: BaseQuestion, key: string): number | null => {
18
- const v = q[key];
19
- if (typeof v === 'number' && Number.isFinite(v)) return v;
20
- if (typeof v === 'string' && v.trim() !== '') {
21
- const n = Number(v);
22
- return Number.isFinite(n) ? n : null;
23
- }
24
- return null;
25
- };
19
+ const questions = questionsJson as Comp20008Question[];
26
20
 
27
21
  export const bank = defineBank({
28
22
  site: {
@@ -36,14 +30,14 @@ export const bank = defineBank({
36
30
  id: 'year',
37
31
  label: 'Year',
38
32
  type: 'select',
39
- getValue: (q) => (typeof q['year'] === 'number' ? String(q['year']) : ''),
33
+ getValue: (q) => String(q.year),
40
34
  searchable: true,
41
35
  },
42
36
  {
43
37
  id: 'semester',
44
38
  label: 'Semester',
45
39
  type: 'select',
46
- getValue: (q) => str(q, 'semester'),
40
+ getValue: (q) => q.semester,
47
41
  options: [
48
42
  { value: 'S1', label: 'Semester 1' },
49
43
  { value: 'S2', label: 'Semester 2' },
@@ -53,7 +47,7 @@ export const bank = defineBank({
53
47
  id: 'exam',
54
48
  label: 'Exam',
55
49
  type: 'text',
56
- getValue: (q) => `${str(q, 'year')} ${str(q, 'semester')}`.trim(),
50
+ getValue: (q) => `${q.year} ${q.semester}`,
57
51
  searchable: true,
58
52
  column: { header: 'Exam', width: 108, sortable: false },
59
53
  },
@@ -61,7 +55,7 @@ export const bank = defineBank({
61
55
  id: 'marks',
62
56
  label: 'Marks',
63
57
  type: 'number',
64
- getValue: (q) => num(q, 'marks'),
58
+ getValue: (q) => q.marks,
65
59
  placeholder: 'Value',
66
60
  column: { header: 'Marks', width: 78 },
67
61
  },
@@ -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
  ]