question-bank-framework 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +105 -2
  2. package/bin/qb.js +119 -0
  3. package/bin/qb.ts +128 -0
  4. package/core/config.tsx +52 -0
  5. package/core/index.ts +35 -0
  6. package/core/types.ts +256 -0
  7. package/framework/components/app-shell.tsx +54 -0
  8. package/framework/components/filter-builder.tsx +471 -0
  9. package/framework/components/markdown-content.tsx +109 -0
  10. package/framework/components/question-bank.tsx +690 -0
  11. package/framework/components/quiz-list.tsx +118 -0
  12. package/framework/components/quiz-page.tsx +454 -0
  13. package/framework/components/ui/badge.tsx +47 -0
  14. package/framework/components/ui/button.tsx +63 -0
  15. package/framework/components/ui/checkbox.tsx +29 -0
  16. package/framework/components/ui/dropdown-menu.tsx +256 -0
  17. package/framework/components/ui/input.tsx +20 -0
  18. package/framework/components/ui/popover.tsx +88 -0
  19. package/framework/components/ui/select.tsx +187 -0
  20. package/framework/components/ui/sheet.tsx +140 -0
  21. package/framework/components/ui/table.tsx +121 -0
  22. package/framework/convex/questionStatuses.ts +30 -0
  23. package/framework/convex/quizzes.ts +50 -0
  24. package/framework/convex/schema.ts +18 -0
  25. package/framework/index.ts +26 -0
  26. package/framework/lib/convex-api.ts +36 -0
  27. package/framework/lib/question-filter-store.ts +116 -0
  28. package/framework/lib/question-filter.ts +376 -0
  29. package/framework/lib/question-status-storage.ts +117 -0
  30. package/framework/lib/quiz-storage.ts +118 -0
  31. package/framework/lib/utils.ts +6 -0
  32. package/package.json +66 -4
  33. package/template/bank.config.ts +72 -0
  34. package/template/content/answers.ts +8 -0
  35. package/template/content/questions.json +76 -0
  36. package/template/src/routes/__root.tsx +44 -0
  37. package/template/src/routes/index.tsx +4 -0
  38. package/template/src/routes/quiz.$quizId.tsx +4 -0
  39. package/template/src/routes/quizzes.tsx +4 -0
package/core/types.ts ADDED
@@ -0,0 +1,256 @@
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, …).
4
+
5
+ export const questionStatuses = ['unanswered', 'revisit', 'complete'] as const;
6
+
7
+ export type QuestionStatus = (typeof questionStatuses)[number];
8
+ export type QuestionStatusMap = Record<string, QuestionStatus>;
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
+ export interface BaseQuestion {
35
+ id: string;
36
+ title: string;
37
+ prompt: string;
38
+ 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;
47
+ }
48
+
49
+ export interface QuizSession {
50
+ id: string;
51
+ title: string;
52
+ questionIds: string[];
53
+ answers: Record<string, QuizAnswer>;
54
+ currentQuestion: number;
55
+ completed: boolean;
56
+ createdAt: number;
57
+ }
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // Bank schema: config-defined fields.
61
+ // ---------------------------------------------------------------------------
62
+
63
+ export type BankFieldType = 'text' | 'number' | 'select' | 'multiselect';
64
+
65
+ export type BankFilterOperatorId =
66
+ | 'is'
67
+ | 'isNot'
68
+ | 'contains'
69
+ | 'doesNotContain'
70
+ | 'startsWith'
71
+ | 'endsWith'
72
+ | 'eq'
73
+ | 'neq'
74
+ | 'gt'
75
+ | 'lt'
76
+ | 'gte'
77
+ | 'lte'
78
+ | 'between'
79
+ | 'isEmpty'
80
+ | 'isNotEmpty';
81
+
82
+ export interface BankFieldOption {
83
+ value: string;
84
+ label: string;
85
+ }
86
+
87
+ export interface BankFieldDef {
88
+ /** Stable field id. Reserved: id, title, tags, status. */
89
+ id: string;
90
+ label: string;
91
+ type: BankFieldType;
92
+ /** Extract the raw value from a question. */
93
+ getValue: (question: BaseQuestion) => string | number | null | readonly string[];
94
+ /**
95
+ * Fixed options for select/multiselect. When omitted, options are derived
96
+ * from distinct values across the bank's questions (sorted).
97
+ */
98
+ options?: readonly BankFieldOption[];
99
+ placeholder?: string;
100
+ /** Include in the free-text search index. Defaults to false (id/title/prompt/tags always searched). */
101
+ searchable?: boolean;
102
+ /** Allowed operators. Defaults per type (same as Notion-style builder). */
103
+ operators?: readonly BankFilterOperatorId[];
104
+ /** Table column behaviour. `false` hides the field from the grid. */
105
+ column?: false | { header?: string; width?: number; sortable?: boolean };
106
+ }
107
+
108
+ export interface BankSiteConfig {
109
+ title: string;
110
+ /** Header brand text. */
111
+ brand: string;
112
+ description?: string;
113
+ }
114
+
115
+ export interface DefineBankInput {
116
+ site: BankSiteConfig;
117
+ /** Namespaces localStorage keys + Convex lookups. e.g. "comp20008". */
118
+ namespace: string;
119
+ /** Subject-specific schema. Built-ins (id/title/tags/status) are automatic. */
120
+ fields: BankFieldDef[];
121
+ questions: BaseQuestion[];
122
+ /** Suggested answers keyed by question id. Missing entries hide "Show answer". */
123
+ answers?: Record<string, string>;
124
+ /** Default sort for the grid, e.g. [{ id: "exam", desc: true }]. */
125
+ defaultSorting?: Array<{ id: string; desc?: boolean }>;
126
+ /** Extra per-question figures, resolved by prompt `![](/figures/…)` etc. */
127
+ publicPath?: string;
128
+ }
129
+
130
+ export interface Bank extends DefineBankInput {
131
+ answers: Record<string, string>;
132
+ questionById: Map<string, BaseQuestion>;
133
+ }
134
+
135
+ export function defineBank(input: DefineBankInput): Bank {
136
+ for (const question of input.questions) {
137
+ validateQuestion(question);
138
+ }
139
+ return {
140
+ ...input,
141
+ answers: input.answers ?? {},
142
+ questionById: new Map(input.questions.map((q) => [q.id, q])),
143
+ };
144
+ }
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
+ }
@@ -0,0 +1,54 @@
1
+ // Framework-owned: eject with `qb reveal framework/components/app-shell.tsx`.
2
+ // Generic header: brand + nav come from the bank, not hardcoded "fieldnotes".
3
+
4
+ import { Link, useRouterState } from '@tanstack/react-router';
5
+ import type { ReactNode } from 'react';
6
+ import { cn } from '../lib/utils.ts';
7
+
8
+ export function AppShell({
9
+ children,
10
+ brand,
11
+ homeLabel = 'Question bank',
12
+ quizzesLabel = 'My quizzes',
13
+ }: {
14
+ children: ReactNode;
15
+ brand: string;
16
+ homeLabel?: string;
17
+ quizzesLabel?: string;
18
+ }) {
19
+ const pathname = useRouterState({ select: (state) => state.location.pathname });
20
+ const navigation = [
21
+ { label: homeLabel, to: '/' },
22
+ { label: quizzesLabel, to: '/quizzes' },
23
+ ] as const;
24
+ const isQuestionBank = pathname === '/';
25
+
26
+ return (
27
+ <div className={cn('flex flex-col bg-white', isQuestionBank ? 'h-dvh overflow-hidden' : 'min-h-dvh')}>
28
+ <header className="sticky top-0 z-20 shrink-0 border-b border-gray-200 bg-white">
29
+ <div className="mx-auto flex h-16 w-full max-w-6xl items-center gap-8 px-4">
30
+ <Link to="/" className="text-lg font-bold text-gray-900">
31
+ {brand}
32
+ </Link>
33
+ <nav className="flex items-center gap-6" aria-label="Main navigation">
34
+ {navigation.map(({ label, to }) => {
35
+ const active = to === '/' ? pathname === '/' : pathname.startsWith(to);
36
+ return (
37
+ <Link
38
+ key={to}
39
+ to={to}
40
+ className={cn('text-sm font-semibold text-gray-600 hover:text-gray-900', active && 'text-gray-900 underline underline-offset-4')}
41
+ >
42
+ {label}
43
+ </Link>
44
+ );
45
+ })}
46
+ </nav>
47
+ </div>
48
+ </header>
49
+ <main className={cn('w-full flex-1', isQuestionBank ? 'flex min-h-0 flex-col overflow-hidden' : 'mx-auto max-w-6xl px-4 py-8')}>
50
+ {children}
51
+ </main>
52
+ </div>
53
+ );
54
+ }