question-bank-framework 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,6 +13,7 @@ import {
13
13
  useReactTable,
14
14
  type ColumnDef,
15
15
  type RowSelectionState,
16
+ type SortingFn,
16
17
  type SortingState,
17
18
  } from '@tanstack/react-table';
18
19
  import { Search } from 'lucide-react';
@@ -52,6 +53,11 @@ type DragSelection = {
52
53
  moved: boolean;
53
54
  };
54
55
 
56
+ // Number-aware, case-insensitive text sort: "Q2" before "Q10".
57
+ const naturalCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
58
+ const naturalSort: SortingFn<BaseQuestion> = (rowA, rowB, columnId) =>
59
+ naturalCollator.compare(String(rowA.getValue(columnId) ?? ''), String(rowB.getValue(columnId) ?? ''));
60
+
55
61
  const previewCloseDelay = 180;
56
62
  const previewOpenDelay = 400;
57
63
  const questionStatusStyles: Record<QuestionStatus, string> = {
@@ -279,13 +285,13 @@ export function QuestionBank() {
279
285
 
280
286
  useEffect(() => {
281
287
  let active = true;
282
- getQuestionStatuses(bank.namespace).then((statuses) => {
288
+ getQuestionStatuses(bank).then((statuses) => {
283
289
  if (active) setQuestionStatusMap(statuses);
284
290
  });
285
291
  return () => {
286
292
  active = false;
287
293
  };
288
- }, [bank.namespace]);
294
+ }, [bank]);
289
295
 
290
296
  const filteredQuestions = useMemo(
291
297
  () =>
@@ -327,6 +333,7 @@ export function QuestionBank() {
327
333
  return <span className="whitespace-normal break-words text-sm text-foreground">{formatCellValue(raw)}</span>;
328
334
  },
329
335
  enableSorting: sortable,
336
+ ...(def.type === 'number' ? {} : { sortingFn: naturalSort }),
330
337
  size: width,
331
338
  } satisfies ColumnDef<BaseQuestion>;
332
339
  });
@@ -383,6 +390,7 @@ export function QuestionBank() {
383
390
  cell: ({ row }) => (
384
391
  <span className="whitespace-normal break-all font-mono text-sm text-muted-foreground">{row.original.id}</span>
385
392
  ),
393
+ sortingFn: naturalSort,
386
394
  size: idWidth,
387
395
  },
388
396
  {
@@ -393,6 +401,7 @@ export function QuestionBank() {
393
401
  accessorKey: 'title',
394
402
  header: 'Question',
395
403
  cell: ({ row }) => <QuestionPreviewPopover question={row.original} />,
404
+ sortingFn: naturalSort,
396
405
  },
397
406
  // Absorbent like title: no fixed size, takes up slack on wide screens.
398
407
  // minTableWidth (below) always reserves 200px for it, so it can
@@ -407,6 +416,7 @@ export function QuestionBank() {
407
416
  id: 'tags',
408
417
  accessorFn: (row) => row.tags.join(', '),
409
418
  header: 'Tags',
419
+ sortingFn: naturalSort,
410
420
  cell: ({ row }) => (
411
421
  <div className="flex min-w-0 flex-wrap gap-1">
412
422
  {row.original.tags.map((tag) => (
@@ -609,13 +619,23 @@ export function QuestionBank() {
609
619
 
610
620
  const selectedIds = Object.keys(rowSelection).filter((id) => rowSelection[id]);
611
621
 
622
+ // Selected questions in the table's current sort order; any selected rows
623
+ // hidden by the current filters follow in bank order.
624
+ const selectedInTableOrder = (exclude: ReadonlySet<string> = new Set()) => {
625
+ const visible = tableRows.map((row) => row.original);
626
+ const visibleIds = new Set(visible.map((question) => question.id));
627
+ return [...visible, ...bank.questions.filter((question) => !visibleIds.has(question.id))].filter(
628
+ (question) => rowSelection[question.id] && !exclude.has(question.id),
629
+ );
630
+ };
631
+
612
632
  const makeQuiz = async () => {
613
- const chosenQuestions = bank.questions.filter((question) => rowSelection[question.id]);
633
+ const chosenQuestions = selectedInTableOrder();
614
634
  if (chosenQuestions.length === 0) return;
615
635
  setCreatingQuiz(true);
616
636
  const title = `${chosenQuestions.length}-question practice set`;
617
637
  try {
618
- const quiz = await createQuiz(bank.namespace, title, chosenQuestions.map((question) => question.id));
638
+ const quiz = await createQuiz(bank, title, chosenQuestions.map((question) => question.id));
619
639
  await navigate({ to: '/quiz/$quizId', params: { quizId: quiz.id } });
620
640
  } finally {
621
641
  setCreatingQuiz(false);
@@ -627,17 +647,17 @@ export function QuestionBank() {
627
647
  if (!open) return;
628
648
  setExistingQuizzes(null);
629
649
  setAddToQuizError(false);
630
- listQuizzes(bank.namespace).then(setExistingQuizzes, () => setExistingQuizzes([]));
650
+ listQuizzes(bank).then(setExistingQuizzes, () => setExistingQuizzes([]));
631
651
  };
632
652
 
633
653
  const addToQuiz = async (quiz: QuizSession) => {
634
654
  const existing = new Set(quiz.questionIds);
635
- const additions = bank.questions.filter((question) => rowSelection[question.id] && !existing.has(question.id));
655
+ const additions = selectedInTableOrder(existing);
636
656
  setAddingToQuizId(quiz.id);
637
657
  setAddToQuizError(false);
638
658
  try {
639
659
  if (additions.length > 0) {
640
- await setQuizQuestions(bank.namespace, quiz, [...quiz.questionIds, ...additions.map((question) => question.id)]);
660
+ await setQuizQuestions(bank, quiz, [...quiz.questionIds, ...additions.map((question) => question.id)]);
641
661
  }
642
662
  setAddToQuizOpen(false);
643
663
  await navigate({ to: '/quiz/$quizId', params: { quizId: quiz.id } });
@@ -166,19 +166,21 @@ export function PairingResponse({
166
166
  <li
167
167
  key={slot.id}
168
168
  className={cn(
169
- 'flex flex-col gap-2 rounded-lg border p-3 sm:flex-row sm:items-center',
169
+ // Wraps instead of squeezing: short items sit beside their select, while
170
+ // wide items (long formulas) take the full row and the select drops below.
171
+ 'flex flex-wrap items-center gap-x-4 gap-y-2 rounded-lg border p-3',
170
172
  state === 'idle' && 'border-gray-200 bg-white',
171
173
  state === 'correct' && 'border-green-300 bg-green-50/50',
172
174
  (state === 'incorrect' || state === 'missing') && 'border-red-300 bg-red-50/50',
173
175
  )}
174
176
  >
175
- <div className="flex min-w-0 flex-1 items-start gap-2">
177
+ <div className="flex min-w-0 max-w-full flex-[1_1_auto] items-start gap-2">
176
178
  <span className="mt-0.5 w-6 shrink-0 text-sm font-semibold tabular-nums text-gray-500">{index + 1}.</span>
177
- <label htmlFor={selectId} className="min-w-0 flex-1 overflow-x-auto text-base leading-7 text-gray-900">
179
+ <label htmlFor={selectId} className="min-w-0 flex-1 overflow-x-auto overflow-y-hidden text-base leading-7 text-gray-900">
178
180
  <MarkdownContent inline content={label} />
179
181
  </label>
180
182
  </div>
181
- <div className="flex shrink-0 flex-col gap-1 pl-8 sm:pl-0">
183
+ <div className="ml-auto flex shrink-0 flex-col gap-1 pl-8 sm:pl-0">
182
184
  <div className="flex items-center gap-2">
183
185
  <OptionSelect
184
186
  id={selectId}
@@ -20,7 +20,7 @@ export function QuizList() {
20
20
 
21
21
  useEffect(() => {
22
22
  let active = true;
23
- listQuizzes(bank.namespace).then((items) => {
23
+ listQuizzes(bank).then((items) => {
24
24
  if (active) setQuizzes(items);
25
25
  }).finally(() => {
26
26
  if (active) setLoading(false);
@@ -28,13 +28,13 @@ export function QuizList() {
28
28
  return () => {
29
29
  active = false;
30
30
  };
31
- }, [bank.namespace]);
31
+ }, [bank]);
32
32
 
33
33
  const removeQuiz = async (quiz: QuizSession) => {
34
34
  setDeletingId(quiz.id);
35
35
  setDeleteError(false);
36
36
  try {
37
- await deleteQuiz(bank.namespace, quiz.id);
37
+ await deleteQuiz(bank, quiz.id);
38
38
  setQuizzes((current) => current.filter((item) => item.id !== quiz.id));
39
39
  } catch {
40
40
  setDeleteError(true);
@@ -44,7 +44,7 @@ export function QuizList() {
44
44
  };
45
45
 
46
46
  const saveQuestions = async (quiz: QuizSession, questionIds: string[]) => {
47
- const updated = await setQuizQuestions(bank.namespace, quiz, questionIds);
47
+ const updated = await setQuizQuestions(bank, quiz, questionIds);
48
48
  setQuizzes((current) => current.map((item) => (item.id === updated.id ? updated : item)));
49
49
  };
50
50
 
@@ -26,7 +26,7 @@ import { MarkdownContent } from './markdown-content';
26
26
  import { formatNumericAnswer, NumericalResponse, optionLetter, PairingResponse } from './question-response';
27
27
  import { setQuestionStatus, getQuestionStatuses } from '../lib/question-status-storage.ts';
28
28
  import { questionStatusLabels } from '../lib/question-filter.ts';
29
- import { getQuiz, saveQuizLocally, saveQuizProgress } from '../lib/quiz-storage.ts';
29
+ import { getQuiz, saveQuizLocally, syncQuiz } from '../lib/quiz-storage.ts';
30
30
  import { cn } from '../lib/utils.ts';
31
31
 
32
32
  const questionStatusStyles: Record<QuestionStatus, string> = {
@@ -68,39 +68,40 @@ export function QuizPage() {
68
68
  const activeIndex = Math.min(session?.currentQuestion ?? 0, Math.max(quizQuestions.length - 1, 0));
69
69
  const activeQuestion = quizQuestions[activeIndex];
70
70
 
71
+ // The session as loaded; not saved back, so merely opening a quiz never
72
+ // bumps its updatedAt.
73
+ const loadedSession = useRef<QuizSession | null>(null);
74
+
71
75
  useEffect(() => {
72
76
  let active = true;
73
77
  setShownAnswerId(null);
74
78
  setChecked({});
75
- getQuiz(bank.namespace, quizId).then((quiz) => {
76
- if (active) setSession(quiz);
79
+ getQuiz(bank, quizId).then((quiz) => {
80
+ if (!active) return;
81
+ loadedSession.current = quiz;
82
+ setSession(quiz);
77
83
  }).finally(() => {
78
84
  if (active) setLoading(false);
79
85
  });
80
- getQuestionStatuses(bank.namespace).then((statuses) => {
86
+ getQuestionStatuses(bank).then((statuses) => {
81
87
  if (active) setStatusMap(statuses);
82
88
  });
83
89
  return () => {
84
90
  active = false;
85
91
  };
86
- }, [quizId, bank.namespace]);
92
+ }, [quizId, bank]);
87
93
 
88
94
  useEffect(() => {
89
- if (!session) return;
90
- saveQuizLocally(bank.namespace, session);
91
- if (session.id.startsWith('local_')) return;
95
+ if (!session || session === loadedSession.current) return;
96
+ // Local copy immediately; server push debounced.
97
+ const saved = saveQuizLocally(bank, session);
98
+ if (!bank.sync) return;
92
99
  setSaving(true);
93
100
  const timeout = window.setTimeout(() => {
94
- saveQuizProgress(
95
- bank.namespace,
96
- session,
97
- { title: session.title, answers: session.answers, currentQuestion: session.currentQuestion, completed: session.completed },
98
- )
99
- .then(() => setSaving(false))
100
- .catch(() => setSaving(false));
101
+ void syncQuiz(bank, saved).finally(() => setSaving(false));
101
102
  }, 500);
102
103
  return () => window.clearTimeout(timeout);
103
- }, [session, bank.namespace]);
104
+ }, [session, bank]);
104
105
 
105
106
  useEffect(() => {
106
107
  if (editingTitle) {
@@ -149,7 +150,7 @@ export function QuizPage() {
149
150
 
150
151
  const updateQuestionStatus = (status: QuestionStatus) => {
151
152
  if (!activeQuestion) return;
152
- void setQuestionStatus(bank.namespace, activeQuestion.id, status);
153
+ void setQuestionStatus(bank, activeQuestion.id, status);
153
154
  setStatusMap((current) => ({ ...current, [activeQuestion.id]: status }));
154
155
  setShownAnswerId(null);
155
156
  setSession((current) =>
@@ -21,7 +21,16 @@ export {
21
21
  export type { FilterField, FilterMatch, FilterOperatorId, FilterRule } from './lib/question-filter.ts';
22
22
 
23
23
  export { createQuestionFilterStore, useQuestionFilterStore } from './lib/question-filter-store.ts';
24
- export { createQuiz, deleteQuiz, getQuiz, listQuizzes, saveQuizLocally, saveQuizProgress } from './lib/quiz-storage.ts';
24
+ export {
25
+ createQuiz,
26
+ deleteQuiz,
27
+ getQuiz,
28
+ listQuizzes,
29
+ saveQuiz,
30
+ saveQuizLocally,
31
+ setQuizQuestions,
32
+ syncQuiz,
33
+ } from './lib/quiz-storage.ts';
25
34
  export { getQuestionStatuses, setQuestionStatus } from './lib/question-status-storage.ts';
26
- export { convexApi } from './lib/convex-api.ts';
35
+ export type { StorageTarget } from './lib/sync-client.ts';
27
36
  export { cn } from './lib/utils.ts';
@@ -1,117 +1,91 @@
1
- // Per-question status (unanswered/revisit/complete), Convex + localStorage.
1
+ // Per-question status (unanswered/revisit/complete). localStorage is the
2
+ // working copy; with `sync` set, changes are pushed to the server and merged
3
+ // back (newest `updatedAt` wins).
2
4
  // Framework-owned: eject with `qb reveal framework/lib/question-status-storage.ts`.
3
5
 
4
- import { ConvexHttpClient } from 'convex/browser';
5
- import { convexApi } from './convex-api.ts';
6
6
  import { questionStatuses, type QuestionStatus, type QuestionStatusMap } from '../../core/types.ts';
7
-
8
- const convexUrl = import.meta.env?.VITE_CONVEX_URL || (import.meta.env?.CONVEX_URL as string | undefined);
9
- const convexClient = convexUrl ? new ConvexHttpClient(convexUrl) : null;
7
+ import { readLocal, syncEnabled, syncRequest, writeLocal, type StorageTarget } from './sync-client.ts';
10
8
 
11
9
  type StoredQuestionStatus = {
12
10
  status: QuestionStatus;
13
11
  updatedAt: number;
12
+ /** Not yet confirmed by the server. */
14
13
  pending: boolean;
15
14
  };
16
15
 
17
- type QuestionStatusDocument = {
18
- questionId: string;
19
- status: QuestionStatus;
20
- updatedAt: number;
21
- };
16
+ type RemoteStatus = { status: QuestionStatus; updatedAt: number };
22
17
 
23
18
  const keyFor = (namespace: string) => `${namespace}-question-bank-statuses`;
24
19
 
25
20
  const isQuestionStatus = (value: unknown): value is QuestionStatus =>
26
21
  typeof value === 'string' && (questionStatuses as readonly string[]).includes(value);
27
22
 
23
+ const isObject = (value: unknown): value is Record<string, unknown> =>
24
+ !!value && typeof value === 'object' && !Array.isArray(value);
25
+
28
26
  const readLocalStatuses = (namespace: string): Record<string, StoredQuestionStatus> => {
29
- if (typeof window === 'undefined') return {};
30
- try {
31
- const value: unknown = JSON.parse(window.localStorage.getItem(keyFor(namespace)) ?? '{}');
32
- if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
33
- return Object.fromEntries(
34
- Object.entries(value).flatMap(([questionId, entry]) => {
35
- if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return [];
36
- const stored = entry as Partial<StoredQuestionStatus>;
37
- if (!isQuestionStatus(stored.status) || typeof stored.updatedAt !== 'number') return [];
38
- return [
39
- [
40
- questionId,
41
- { status: stored.status, updatedAt: stored.updatedAt, pending: stored.pending === true },
42
- ],
43
- ];
44
- }),
45
- );
46
- } catch {
47
- return {};
48
- }
27
+ const value = readLocal(keyFor(namespace), {}, isObject);
28
+ return Object.fromEntries(
29
+ Object.entries(value).flatMap(([questionId, entry]) => {
30
+ if (!isObject(entry) || !isQuestionStatus(entry.status) || typeof entry.updatedAt !== 'number') return [];
31
+ return [[questionId, { status: entry.status, updatedAt: entry.updatedAt, pending: entry.pending === true }]];
32
+ }),
33
+ );
49
34
  };
50
35
 
51
- const writeLocalStatuses = (namespace: string, statuses: Record<string, StoredQuestionStatus>) => {
52
- if (typeof window !== 'undefined') window.localStorage.setItem(keyFor(namespace), JSON.stringify(statuses));
53
- };
36
+ const writeLocalStatuses = (namespace: string, statuses: Record<string, StoredQuestionStatus>) =>
37
+ writeLocal(keyFor(namespace), statuses);
54
38
 
55
39
  const toStatusMap = (statuses: Record<string, StoredQuestionStatus>): QuestionStatusMap =>
56
40
  Object.fromEntries(Object.entries(statuses).map(([questionId, entry]) => [questionId, entry.status]));
57
41
 
58
- async function syncPendingStatuses(namespace: string, statuses: Record<string, StoredQuestionStatus>) {
59
- if (!convexClient) return statuses;
60
- await Promise.all(
61
- Object.entries(statuses).map(async ([questionId, entry]) => {
62
- if (!entry.pending) return;
63
- try {
64
- const updatedAt = await convexClient.mutation(convexApi.questionStatuses.set, {
65
- questionId,
66
- status: entry.status,
67
- });
68
- const latest = readLocalStatuses(namespace);
69
- if (latest[questionId]?.pending && latest[questionId]?.updatedAt === entry.updatedAt) {
70
- latest[questionId] = { status: entry.status, updatedAt, pending: false };
71
- writeLocalStatuses(namespace, latest);
72
- statuses[questionId] = latest[questionId];
73
- }
74
- } catch {
75
- // Keep the local status pending so it can be retried later.
76
- }
77
- }),
78
- );
79
- return statuses;
42
+ /** Push one status; on success mark it confirmed unless it changed meanwhile. */
43
+ async function pushStatus(target: StorageTarget, questionId: string, entry: StoredQuestionStatus): Promise<void> {
44
+ const { data } = await syncRequest<RemoteStatus>(target, `/statuses/${encodeURIComponent(questionId)}`, {
45
+ method: 'PUT',
46
+ body: { status: entry.status, updatedAt: entry.updatedAt },
47
+ });
48
+ const latest = readLocalStatuses(target.namespace);
49
+ const current = latest[questionId];
50
+ if (current && current.updatedAt === entry.updatedAt) {
51
+ latest[questionId] = { status: data.status, updatedAt: data.updatedAt, pending: false };
52
+ writeLocalStatuses(target.namespace, latest);
53
+ }
80
54
  }
81
55
 
82
- export async function getQuestionStatuses(namespace: string): Promise<QuestionStatusMap> {
83
- const localStatuses = readLocalStatuses(namespace);
84
- if (!convexClient) return toStatusMap(localStatuses);
85
- await syncPendingStatuses(namespace, localStatuses);
56
+ export async function getQuestionStatuses(target: StorageTarget): Promise<QuestionStatusMap> {
57
+ const local = readLocalStatuses(target.namespace);
58
+ if (!syncEnabled(target)) return toStatusMap(local);
86
59
  try {
87
- const documents = (await convexClient.query(convexApi.questionStatuses.list, {})) as QuestionStatusDocument[];
60
+ await Promise.all(
61
+ Object.entries(local)
62
+ .filter(([, entry]) => entry.pending)
63
+ .map(([questionId, entry]) => pushStatus(target, questionId, entry)),
64
+ );
65
+ const { data } = await syncRequest<Record<string, RemoteStatus>>(target, '/statuses');
88
66
  const merged: Record<string, StoredQuestionStatus> = Object.fromEntries(
89
- documents.map((document) => [document.questionId, { status: document.status, updatedAt: document.updatedAt, pending: false }]),
67
+ Object.entries(data).map(([questionId, entry]) => [questionId, { ...entry, pending: false }]),
90
68
  );
91
- for (const [questionId, localStatus] of Object.entries(localStatuses)) {
92
- if (localStatus.pending || !merged[questionId]) merged[questionId] = localStatus;
69
+ for (const [questionId, entry] of Object.entries(readLocalStatuses(target.namespace))) {
70
+ const server = merged[questionId];
71
+ if (!server || entry.updatedAt > server.updatedAt) merged[questionId] = { ...entry, pending: true };
93
72
  }
94
- writeLocalStatuses(namespace, merged);
73
+ writeLocalStatuses(target.namespace, merged);
95
74
  return toStatusMap(merged);
96
75
  } catch {
97
- return toStatusMap(localStatuses);
76
+ return toStatusMap(readLocalStatuses(target.namespace));
98
77
  }
99
78
  }
100
79
 
101
- export async function setQuestionStatus(namespace: string, questionId: string, status: QuestionStatus): Promise<void> {
102
- const statuses = readLocalStatuses(namespace);
103
- const localStatus: StoredQuestionStatus = { status, updatedAt: Date.now(), pending: true };
104
- statuses[questionId] = localStatus;
105
- writeLocalStatuses(namespace, statuses);
106
- if (!convexClient) return;
80
+ export async function setQuestionStatus(target: StorageTarget, questionId: string, status: QuestionStatus): Promise<void> {
81
+ const statuses = readLocalStatuses(target.namespace);
82
+ const entry: StoredQuestionStatus = { status, updatedAt: Date.now(), pending: true };
83
+ statuses[questionId] = entry;
84
+ writeLocalStatuses(target.namespace, statuses);
85
+ if (!syncEnabled(target)) return;
107
86
  try {
108
- const updatedAt = await convexClient.mutation(convexApi.questionStatuses.set, { questionId, status });
109
- const latest = readLocalStatuses(namespace);
110
- if (latest[questionId]?.pending && latest[questionId]?.updatedAt === localStatus.updatedAt) {
111
- latest[questionId] = { status, updatedAt, pending: false };
112
- writeLocalStatuses(namespace, latest);
113
- }
87
+ await pushStatus(target, questionId, entry);
114
88
  } catch {
115
- // The local copy remains available and will be retried on the next status read.
89
+ // Stays pending; retried on the next getQuestionStatuses.
116
90
  }
117
91
  }