question-bank-framework 0.1.1 → 0.3.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.
@@ -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.string()),
8
+ answers: v.record(v.string(), v.union(v.string(), v.array(v.string()))),
9
9
  currentQuestion: v.number(),
10
10
  completed: v.boolean(),
11
11
  createdAt: v.number(),
@@ -2,16 +2,22 @@
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, QuizSession } from '../../core/types.ts';
5
+ import type { QuestionStatus, QuizAnswer, 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, string>;
11
+ answers: Record<string, QuizAnswer>;
12
12
  currentQuestion: number;
13
13
  completed: boolean;
14
14
  };
15
+ type SetQuestionsArgs = {
16
+ quizId: string;
17
+ questionIds: string[];
18
+ answers: Record<string, QuizAnswer>;
19
+ currentQuestion: number;
20
+ };
15
21
  type QuizDocument = QuizSession & { _id: string; _creationTime: number };
16
22
  type QuestionStatusDocument = {
17
23
  _id: string;
@@ -28,6 +34,7 @@ export const convexApi = {
28
34
  list: makeFunctionReference<'query', Record<string, never>, QuizDocument[]>('quizzes:list'),
29
35
  remove: makeFunctionReference<'mutation', { quizId: string }, null>('quizzes:remove'),
30
36
  saveProgress: makeFunctionReference<'mutation', ProgressArgs, null>('quizzes:saveProgress'),
37
+ setQuestions: makeFunctionReference<'mutation', SetQuestionsArgs, null>('quizzes:setQuestions'),
31
38
  },
32
39
  questionStatuses: {
33
40
  list: makeFunctionReference<'query', Record<string, never>, QuestionStatusDocument[]>('questionStatuses:list'),
@@ -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 { questionStatuses, type BankFieldDef, type BankFilterOperatorId, type BaseQuestion, type QuestionStatus } from '../../core/types.ts';
6
+ import { questionKind, questionKindLabels, questionKinds, 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,6 +101,13 @@ 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
+ },
104
111
  {
105
112
  id: 'status',
106
113
  label: 'Status',
@@ -128,7 +135,11 @@ function distinctStrings(values: Array<string | number | null | readonly string[
128
135
  * Build filter fields from bank config. Select/multiselect options default to
129
136
  * the distinct values across questions unless the field declares `options`.
130
137
  */
131
- export function createFilterFields(questions: readonly BaseQuestion[], fieldDefs: readonly BankFieldDef[]): FilterField[] {
138
+ export function createFilterFields(
139
+ questions: readonly BaseQuestion[],
140
+ fieldDefs: readonly BankFieldDef[],
141
+ hideTagsColumn = false,
142
+ ): FilterField[] {
132
143
  const customs: FilterField[] = fieldDefs.map((def) => {
133
144
  let options: readonly FilterOption[] | undefined;
134
145
  if (def.options) {
@@ -156,7 +167,7 @@ export function createFilterFields(questions: readonly BaseQuestion[], fieldDefs
156
167
  };
157
168
  });
158
169
 
159
- const builtins = builtinFields();
170
+ const builtins = builtinFields().filter((field) => !(hideTagsColumn && field.id === 'tags'));
160
171
  // Attach tag options derived from the bank (like the original).
161
172
  const tags = [...new Set(questions.flatMap((q) => q.tags))].sort((a, b) => a.localeCompare(b));
162
173
  const withTags = builtins.map((field) =>
@@ -220,7 +231,9 @@ function resolveFieldValue(
220
231
  if (ruleField === 'title') return question.title;
221
232
  if (ruleField === 'id') return question.id;
222
233
  if (ruleField === 'tags') return question.tags;
223
- return (question[ruleField] as string | number | null | readonly string[]) ?? null;
234
+ // status/kind are handled by matchesRule before reaching here. Any other
235
+ // id is stale (no def, not a built-in) — match nothing.
236
+ return null;
224
237
  }
225
238
  return def.getValue(question);
226
239
  }
@@ -321,6 +334,7 @@ export function matchesRule(
321
334
  ): boolean {
322
335
  const type = filterFieldType(fields, rule.field);
323
336
  if (rule.field === 'status') return matchSelect(status, rule);
337
+ if (rule.field === 'kind') return matchSelect(questionKind(question), rule);
324
338
  const def = fieldDefs.find((d) => d.id === rule.field);
325
339
  const raw = resolveFieldValue(def, rule.field, question);
326
340
  if (type === 'text') return matchText(toText(raw), rule);
@@ -344,7 +358,7 @@ export function matchesFilters(
344
358
  : active.some((rule) => matchesRule(rule, fields, fieldDefs, question, status));
345
359
  }
346
360
 
347
- /** Free-text search across id/title/prompt/tags plus `searchable` custom fields. */
361
+ /** Free-text search across id/title/prompt/tags/options plus `searchable` custom fields. */
348
362
  export function matchesSearch(
349
363
  question: BaseQuestion,
350
364
  query: string,
@@ -352,7 +366,13 @@ export function matchesSearch(
352
366
  ): boolean {
353
367
  const q = query.trim().toLowerCase();
354
368
  if (!q) return true;
355
- const parts = [question.id, question.title, question.prompt, question.tags.join(' ')];
369
+ const parts = [
370
+ question.id,
371
+ question.title,
372
+ question.prompt,
373
+ question.tags.join(' '),
374
+ (question.options ?? []).map((o) => o.text).join(' '),
375
+ ];
356
376
  for (const def of fieldDefs) {
357
377
  if (!def.searchable) continue;
358
378
  const raw = def.getValue(question);
@@ -104,6 +104,28 @@ export async function saveQuizProgress(
104
104
  });
105
105
  }
106
106
 
107
+ // Replaces a quiz's question list (reorder / remove / append). Answers for
108
+ // removed questions are dropped and the current position is kept on the same
109
+ // question where possible.
110
+ export async function setQuizQuestions(namespace: string, session: QuizSession, questionIds: string[]): Promise<QuizSession> {
111
+ const kept = new Set(questionIds);
112
+ const answers = Object.fromEntries(Object.entries(session.answers).filter(([id]) => kept.has(id)));
113
+ const currentId = session.questionIds[session.currentQuestion];
114
+ const currentIndex = currentId === undefined ? -1 : questionIds.indexOf(currentId);
115
+ const currentQuestion = currentIndex >= 0 ? currentIndex : Math.min(session.currentQuestion, Math.max(questionIds.length - 1, 0));
116
+ const updated: QuizSession = { ...session, questionIds, answers, currentQuestion };
117
+ if (convexClient && !updated.id.startsWith('local_')) {
118
+ await convexClient.mutation(convexApi.quizzes.setQuestions, {
119
+ quizId: updated.id,
120
+ questionIds,
121
+ answers,
122
+ currentQuestion,
123
+ });
124
+ }
125
+ saveQuizLocally(namespace, updated);
126
+ return updated;
127
+ }
128
+
107
129
  export async function listQuizzes(namespace: string): Promise<QuizSession[]> {
108
130
  const localQuizzes = readLocalQuizzes(namespace);
109
131
  if (!convexClient) return localQuizzes;
@@ -0,0 +1,58 @@
1
+ /* shadcn-style theme tokens consumed by question-bank-framework UI components.
2
+ * Import this from your app stylesheet (after tailwindcss) instead of
3
+ * copy-pasting the tokens, so every bank inherits upstream token changes:
4
+ *
5
+ * @import 'tailwindcss';
6
+ * @source '../node_modules/question-bank-framework';
7
+ * @import 'question-bank-framework/framework/styles/theme.css';
8
+ */
9
+
10
+ @theme inline {
11
+ --font-sans: 'DM Sans', ui-sans-serif, system-ui, sans-serif;
12
+ --font-display: 'Fraunces', Georgia, serif;
13
+ --font-mono: 'DM Mono', ui-monospace, monospace;
14
+ --radius-sm: calc(var(--radius) - 4px);
15
+ --radius-md: calc(var(--radius) - 2px);
16
+ --radius-lg: var(--radius);
17
+ --radius-xl: calc(var(--radius) + 4px);
18
+ --color-background: var(--background);
19
+ --color-foreground: var(--foreground);
20
+ --color-card: var(--card);
21
+ --color-card-foreground: var(--card-foreground);
22
+ --color-popover: var(--popover);
23
+ --color-popover-foreground: var(--popover-foreground);
24
+ --color-primary: var(--primary);
25
+ --color-primary-foreground: var(--primary-foreground);
26
+ --color-secondary: var(--secondary);
27
+ --color-secondary-foreground: var(--secondary-foreground);
28
+ --color-muted: var(--muted);
29
+ --color-muted-foreground: var(--muted-foreground);
30
+ --color-accent: var(--accent);
31
+ --color-accent-foreground: var(--accent-foreground);
32
+ --color-destructive: var(--destructive);
33
+ --color-border: var(--border);
34
+ --color-input: var(--input);
35
+ --color-ring: var(--ring);
36
+ }
37
+
38
+ :root {
39
+ --radius: 0.625rem;
40
+ --background: #ffffff;
41
+ --foreground: #111827;
42
+ --card: #ffffff;
43
+ --card-foreground: #111827;
44
+ --popover: #ffffff;
45
+ --popover-foreground: #111827;
46
+ --primary: #25564c;
47
+ --primary-foreground: #ffffff;
48
+ --secondary: #f1f5f2;
49
+ --secondary-foreground: #263a2f;
50
+ --muted: #f3f4f6;
51
+ --muted-foreground: #6b7280;
52
+ --accent: #eef3ef;
53
+ --accent-foreground: #263a2f;
54
+ --destructive: #b91c1c;
55
+ --border: #d1d5db;
56
+ --input: #d1d5db;
57
+ --ring: #78a589;
58
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "question-bank-framework",
3
- "version": "0.1.1",
3
+ "version": "0.3.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",
@@ -62,7 +62,6 @@
62
62
  "version-packages": "changeset version",
63
63
  "release": "pnpm build:bin && changeset publish",
64
64
  "reveal": "node ./bin/qb.js reveal",
65
- "template:check": "node ./bin/qb.js check-template",
66
65
  "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
66
  }
68
67
  }
@@ -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,4 +3,6 @@
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.`,
6
8
  };
@@ -1,6 +1,7 @@
1
1
  [
2
2
  {
3
3
  "id": "2019-s1-q01",
4
+ "kind": "written",
4
5
  "year": 2019,
5
6
  "semester": "S1",
6
7
  "number": 1,
@@ -12,6 +13,7 @@
12
13
  },
13
14
  {
14
15
  "id": "2019-s1-q02",
16
+ "kind": "written",
15
17
  "year": 2019,
16
18
  "semester": "S1",
17
19
  "number": 2,
@@ -23,6 +25,7 @@
23
25
  },
24
26
  {
25
27
  "id": "ioa-001",
28
+ "kind": "written",
26
29
  "year": 2026,
27
30
  "semester": "S2",
28
31
  "number": 1,
@@ -31,5 +34,43 @@
31
34
  "prompt": "What is your research question, and what result would count as evidence?",
32
35
  "source": "assignment-ioa",
33
36
  "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"]
34
75
  }
35
76
  ]
@@ -0,0 +1,7 @@
1
+ @import url('https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=DM+Sans:wght@400;500;600;700;800&family=Fraunces:opsz,wght@9..144,600;9..144,700&display=swap');
2
+ @import 'katex/dist/katex.min.css';
3
+ @import 'tailwindcss';
4
+ @import 'tw-animate-css';
5
+ @import 'question-bank-framework/framework/styles/theme.css';
6
+
7
+ @source '../../node_modules/question-bank-framework';