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.
- package/README.md +46 -5
- package/bin/qb.js +2 -1
- package/bin/qb.ts +2 -1
- package/core/types.ts +27 -7
- package/framework/cloudflare/index.ts +240 -0
- package/framework/components/markdown-content.tsx +60 -23
- package/framework/components/question-bank.tsx +27 -7
- package/framework/components/question-response.tsx +6 -4
- package/framework/components/quiz-list.tsx +4 -4
- package/framework/components/quiz-page.tsx +18 -17
- package/framework/index.ts +11 -2
- package/framework/lib/question-status-storage.ts +53 -79
- package/framework/lib/quiz-storage.ts +112 -102
- package/framework/lib/sync-client.ts +45 -0
- package/framework/styles/theme.css +4 -0
- package/package.json +2 -3
- package/template/bank.config.ts +2 -0
- package/template/src/routes/styles.css +0 -1
- package/template/src/server.ts +17 -0
- package/template/vite.config.ts +10 -0
- package/template/wrangler.jsonc +16 -0
- package/framework/convex/questionStatuses.ts +0 -30
- package/framework/convex/quizzes.ts +0 -62
- package/framework/convex/schema.ts +0 -18
- package/framework/lib/convex-api.ts +0 -43
|
@@ -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
|
|
288
|
+
getQuestionStatuses(bank).then((statuses) => {
|
|
283
289
|
if (active) setQuestionStatusMap(statuses);
|
|
284
290
|
});
|
|
285
291
|
return () => {
|
|
286
292
|
active = false;
|
|
287
293
|
};
|
|
288
|
-
}, [bank
|
|
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 =
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
76
|
-
if (active)
|
|
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
|
|
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
|
|
92
|
+
}, [quizId, bank]);
|
|
87
93
|
|
|
88
94
|
useEffect(() => {
|
|
89
|
-
if (!session) return;
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
153
|
+
void setQuestionStatus(bank, activeQuestion.id, status);
|
|
153
154
|
setStatusMap((current) => ({ ...current, [activeQuestion.id]: status }));
|
|
154
155
|
setShownAnswerId(null);
|
|
155
156
|
setSession((current) =>
|
package/framework/index.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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)
|
|
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
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
await
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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(
|
|
83
|
-
const
|
|
84
|
-
if (!
|
|
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
|
-
|
|
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
|
-
|
|
67
|
+
Object.entries(data).map(([questionId, entry]) => [questionId, { ...entry, pending: false }]),
|
|
90
68
|
);
|
|
91
|
-
for (const [questionId,
|
|
92
|
-
|
|
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(
|
|
76
|
+
return toStatusMap(readLocalStatuses(target.namespace));
|
|
98
77
|
}
|
|
99
78
|
}
|
|
100
79
|
|
|
101
|
-
export async function setQuestionStatus(
|
|
102
|
-
const statuses = readLocalStatuses(namespace);
|
|
103
|
-
const
|
|
104
|
-
statuses[questionId] =
|
|
105
|
-
writeLocalStatuses(namespace, statuses);
|
|
106
|
-
if (!
|
|
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
|
-
|
|
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
|
-
//
|
|
89
|
+
// Stays pending; retried on the next getQuestionStatuses.
|
|
116
90
|
}
|
|
117
91
|
}
|