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 +0 -0
- package/core/config.tsx +0 -14
- package/core/index.ts +1 -19
- package/core/types.ts +24 -158
- package/framework/components/question-bank.tsx +1 -37
- package/framework/components/quiz-list.tsx +1 -9
- package/framework/components/quiz-page.tsx +32 -246
- package/framework/convex/quizzes.ts +1 -1
- package/framework/convex/schema.ts +1 -1
- package/framework/lib/convex-api.ts +2 -2
- package/framework/lib/question-filter.ts +6 -18
- package/package.json +11 -11
- package/template/bank.config.ts +14 -20
- package/template/content/answers.ts +0 -2
- package/template/content/questions.json +0 -41
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
|
-
//
|
|
3
|
-
//
|
|
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
|
-
|
|
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,
|
|
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
|
-
/**
|
|
93
|
-
|
|
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:
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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,
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
<
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
293
|
-
<
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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 && (
|
|
202
|
+
{suggestedAnswer && (
|
|
404
203
|
<div className="mt-5">
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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
|
-
<
|
|
428
|
-
|
|
429
|
-
|
|
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.
|
|
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.
|
|
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,
|
|
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,
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
+
}
|
package/template/bank.config.ts
CHANGED
|
@@ -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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
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) =>
|
|
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) =>
|
|
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) => `${
|
|
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) =>
|
|
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
|
]
|