question-bank-framework 0.9.0 → 0.10.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/README.md +3 -1
- package/framework/components/exam-timer.tsx +29 -7
- package/framework/index.ts +1 -1
- package/framework/lib/exam-timer.ts +29 -10
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -60,7 +60,9 @@ time (e.g. 08:30) and reading/writing minutes, then press Start: the top right
|
|
|
60
60
|
of the header shows the mock exam time, starting at 08:30, with a book icon
|
|
61
61
|
during reading time and a pen during writing time. It updates once a minute.
|
|
62
62
|
Toasts announce reading time over, 30 and 10 minutes remaining, and exam over;
|
|
63
|
-
the clock disappears once the exam is over.
|
|
63
|
+
the clock disappears once the exam is over. Pause freezes the clock (the
|
|
64
|
+
header shows a pause icon) and Resume carries on from the same mock time, so
|
|
65
|
+
breaks don't eat into the exam. The timer lives in localStorage,
|
|
64
66
|
so it survives reloads and shows in every open tab.
|
|
65
67
|
|
|
66
68
|
## Sync (Cloudflare Durable Objects)
|
|
@@ -1,16 +1,27 @@
|
|
|
1
1
|
// Mock-exam clock: <ExamTimerBadge> shows the mock time in the header while an
|
|
2
|
-
// exam is running; <ExamTimerPage> (the /timer route) is the only place to start
|
|
2
|
+
// exam is running; <ExamTimerPage> (the /timer route) is the only place to start,
|
|
3
|
+
// pause, resume or stop it.
|
|
3
4
|
// Framework-owned: eject with `qb reveal framework/components/exam-timer.tsx`.
|
|
4
5
|
|
|
5
6
|
import { useEffect, useRef, useState, type FormEvent } from 'react';
|
|
6
|
-
import { BookOpen, PenLine } from 'lucide-react';
|
|
7
|
+
import { BookOpen, Pause, PenLine } from 'lucide-react';
|
|
7
8
|
import { useBank } from '../../core/config.tsx';
|
|
8
9
|
import { Button } from './ui/button';
|
|
9
10
|
import { Card, CardContent, CardFooter } from './ui/card';
|
|
10
11
|
import { Field, FieldGroup, FieldLabel } from './ui/field';
|
|
11
12
|
import { Input } from './ui/input';
|
|
12
13
|
import { toast } from './ui/toast';
|
|
13
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
examState,
|
|
16
|
+
formatClock,
|
|
17
|
+
MINUTE,
|
|
18
|
+
pauseExamTimer,
|
|
19
|
+
resumeExamTimer,
|
|
20
|
+
setExamTimer,
|
|
21
|
+
useExamNow,
|
|
22
|
+
useExamTimer,
|
|
23
|
+
type ExamTimer,
|
|
24
|
+
} from '../lib/exam-timer.ts';
|
|
14
25
|
|
|
15
26
|
/** Moments to announce, in minutes after Start. */
|
|
16
27
|
const milestones = ({ readingMinutes: reading, writingMinutes: writing }: ExamTimer) => {
|
|
@@ -43,14 +54,15 @@ export function ExamTimerBadge() {
|
|
|
43
54
|
}, [timer?.startedAt, state?.elapsed]);
|
|
44
55
|
|
|
45
56
|
if (!state || state.phase === 'over') return null;
|
|
46
|
-
const Icon = state.phase === 'reading' ? BookOpen : PenLine;
|
|
57
|
+
const Icon = state.paused ? Pause : state.phase === 'reading' ? BookOpen : PenLine;
|
|
58
|
+
const label = `${state.phase === 'reading' ? 'Reading time' : 'Writing time'}${state.paused ? ' (paused)' : ''}`;
|
|
47
59
|
return (
|
|
48
60
|
<span
|
|
49
|
-
className=
|
|
50
|
-
title={
|
|
61
|
+
className={`flex items-center gap-1.5 text-sm font-medium tabular-nums ${state.paused ? 'text-muted-foreground' : 'text-foreground'}`}
|
|
62
|
+
title={label}
|
|
51
63
|
>
|
|
52
64
|
<Icon aria-hidden="true" className="size-4 text-muted-foreground" />
|
|
53
|
-
<span className="sr-only">{
|
|
65
|
+
<span className="sr-only">{label}, mock exam time </span>
|
|
54
66
|
{state.clock}
|
|
55
67
|
</span>
|
|
56
68
|
);
|
|
@@ -74,6 +86,7 @@ export function ExamTimerPage({
|
|
|
74
86
|
const timer = useExamTimer(namespace);
|
|
75
87
|
const now = useExamNow(timer);
|
|
76
88
|
const running = timer !== null && examState(timer, now).phase !== 'over';
|
|
89
|
+
const paused = running && timer.pausedAt !== undefined;
|
|
77
90
|
const [start, setStart] = useState(defaultStart);
|
|
78
91
|
const [reading, setReading] = useState(String(defaultReadingMinutes));
|
|
79
92
|
const [writing, setWriting] = useState(String(defaultWritingMinutes));
|
|
@@ -121,6 +134,15 @@ export function ExamTimerPage({
|
|
|
121
134
|
</CardContent>
|
|
122
135
|
<CardFooter className="gap-2">
|
|
123
136
|
<Button type="submit">{running ? 'Restart' : 'Start'}</Button>
|
|
137
|
+
{running && (
|
|
138
|
+
<Button
|
|
139
|
+
type="button"
|
|
140
|
+
variant="outline"
|
|
141
|
+
onClick={() => (paused ? resumeExamTimer(namespace, timer) : pauseExamTimer(namespace, timer))}
|
|
142
|
+
>
|
|
143
|
+
{paused ? 'Resume' : 'Pause'}
|
|
144
|
+
</Button>
|
|
145
|
+
)}
|
|
124
146
|
{running && (
|
|
125
147
|
<Button type="button" variant="ghost" onClick={() => setExamTimer(namespace, null)}>
|
|
126
148
|
Stop
|
package/framework/index.ts
CHANGED
|
@@ -33,7 +33,7 @@ export {
|
|
|
33
33
|
syncQuiz,
|
|
34
34
|
} from './lib/quiz-storage.ts';
|
|
35
35
|
export { getQuestionStatuses, setQuestionStatus } from './lib/question-status-storage.ts';
|
|
36
|
-
export { examState, setExamTimer, useExamTimer } from './lib/exam-timer.ts';
|
|
36
|
+
export { examState, pauseExamTimer, resumeExamTimer, setExamTimer, useExamTimer } from './lib/exam-timer.ts';
|
|
37
37
|
export type { ExamState, ExamTimer } from './lib/exam-timer.ts';
|
|
38
38
|
export { Toaster, toast } from './components/ui/toast.tsx';
|
|
39
39
|
export type { StorageTarget } from './lib/sync-client.ts';
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Mock-exam clock: pressing Start on /timer pretends it's the exam's start time
|
|
2
2
|
// (e.g. 08:30) right now, then the clock runs forward from there. Kept in
|
|
3
|
-
// localStorage so it survives reloads and is shared by every open tab.
|
|
3
|
+
// localStorage so it survives reloads and is shared by every open tab. Pausing
|
|
4
|
+
// freezes the clock; resuming shifts `startedAt` forward by the paused time.
|
|
4
5
|
// Framework-owned: eject with `qb reveal framework/lib/exam-timer.ts`.
|
|
5
6
|
|
|
6
7
|
import { useEffect, useState, useSyncExternalStore } from 'react';
|
|
@@ -12,10 +13,13 @@ export type ExamTimer = {
|
|
|
12
13
|
startedAt: number;
|
|
13
14
|
readingMinutes: number;
|
|
14
15
|
writingMinutes: number;
|
|
16
|
+
/** Set while paused: when Pause was pressed (epoch ms). The clock is frozen at this moment. */
|
|
17
|
+
pausedAt?: number;
|
|
15
18
|
};
|
|
16
19
|
|
|
17
20
|
export type ExamState = {
|
|
18
21
|
phase: 'reading' | 'writing' | 'over';
|
|
22
|
+
paused: boolean;
|
|
19
23
|
/** Mock clock time, "HH:MM". */
|
|
20
24
|
clock: string;
|
|
21
25
|
/** Ms since Start was pressed. */
|
|
@@ -28,10 +32,9 @@ const keyFor = (namespace: string) => `${namespace}-question-bank-exam-timer`;
|
|
|
28
32
|
|
|
29
33
|
const isTimer = (value: unknown): value is ExamTimer => {
|
|
30
34
|
if (!value || typeof value !== 'object') return false;
|
|
31
|
-
const { startMinutes, startedAt, readingMinutes, writingMinutes } = value as Record<string, unknown>;
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
);
|
|
35
|
+
const { startMinutes, startedAt, readingMinutes, writingMinutes, pausedAt } = value as Record<string, unknown>;
|
|
36
|
+
const isTime = (n: unknown) => typeof n === 'number' && Number.isFinite(n) && n >= 0;
|
|
37
|
+
return [startMinutes, startedAt, readingMinutes, writingMinutes].every(isTime) && (pausedAt === undefined || isTime(pausedAt));
|
|
35
38
|
};
|
|
36
39
|
|
|
37
40
|
const parse = (raw: string | null): ExamTimer | null => {
|
|
@@ -51,6 +54,19 @@ export function setExamTimer(namespace: string, timer: ExamTimer | null): void {
|
|
|
51
54
|
window.dispatchEvent(new Event(CHANGE_EVENT));
|
|
52
55
|
}
|
|
53
56
|
|
|
57
|
+
/** Freeze the clock at `now`. No-op if already paused. */
|
|
58
|
+
export function pauseExamTimer(namespace: string, timer: ExamTimer, now = Date.now()): void {
|
|
59
|
+
if (timer.pausedAt !== undefined) return;
|
|
60
|
+
setExamTimer(namespace, { ...timer, pausedAt: now });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Carry on from where it was paused, by shifting `startedAt` forward by the time spent paused. */
|
|
64
|
+
export function resumeExamTimer(namespace: string, timer: ExamTimer, now = Date.now()): void {
|
|
65
|
+
if (timer.pausedAt === undefined) return;
|
|
66
|
+
const { pausedAt, ...rest } = timer;
|
|
67
|
+
setExamTimer(namespace, { ...rest, startedAt: rest.startedAt + Math.max(0, now - pausedAt) });
|
|
68
|
+
}
|
|
69
|
+
|
|
54
70
|
function subscribe(onChange: () => void) {
|
|
55
71
|
window.addEventListener('storage', onChange);
|
|
56
72
|
window.addEventListener(CHANGE_EVENT, onChange);
|
|
@@ -76,22 +92,25 @@ export const formatClock = (minutesAfterMidnight: number) => {
|
|
|
76
92
|
};
|
|
77
93
|
|
|
78
94
|
export function examState(timer: ExamTimer, now: number): ExamState {
|
|
79
|
-
const
|
|
95
|
+
const paused = timer.pausedAt !== undefined;
|
|
96
|
+
const elapsed = Math.max(0, (timer.pausedAt ?? now) - timer.startedAt);
|
|
80
97
|
const minutes = elapsed / MINUTE;
|
|
81
98
|
const phase =
|
|
82
99
|
minutes < timer.readingMinutes ? 'reading' : minutes < timer.readingMinutes + timer.writingMinutes ? 'writing' : 'over';
|
|
83
|
-
return { phase, clock: formatClock(timer.startMinutes + minutes), elapsed };
|
|
100
|
+
return { phase, paused, clock: formatClock(timer.startMinutes + minutes), elapsed };
|
|
84
101
|
}
|
|
85
102
|
|
|
86
103
|
/**
|
|
87
104
|
* Current time, re-read each time the mock clock ticks over a minute (and
|
|
88
|
-
* when the tab becomes visible again). Never re-renders more than once a minute
|
|
105
|
+
* when the tab becomes visible again). Never re-renders more than once a minute,
|
|
106
|
+
* and doesn't tick at all while paused.
|
|
89
107
|
*/
|
|
90
108
|
export function useExamNow(timer: ExamTimer | null): number {
|
|
91
109
|
const [now, setNow] = useState(() => Date.now());
|
|
92
110
|
const startedAt = timer?.startedAt;
|
|
111
|
+
const paused = timer?.pausedAt !== undefined;
|
|
93
112
|
useEffect(() => {
|
|
94
|
-
if (startedAt === undefined) return;
|
|
113
|
+
if (startedAt === undefined || paused) return;
|
|
95
114
|
let timeout: ReturnType<typeof setTimeout>;
|
|
96
115
|
const tick = () => {
|
|
97
116
|
const current = Date.now();
|
|
@@ -110,6 +129,6 @@ export function useExamNow(timer: ExamTimer | null): number {
|
|
|
110
129
|
clearTimeout(timeout);
|
|
111
130
|
document.removeEventListener('visibilitychange', onVisible);
|
|
112
131
|
};
|
|
113
|
-
}, [startedAt]);
|
|
132
|
+
}, [startedAt, paused]);
|
|
114
133
|
return now;
|
|
115
134
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "question-bank-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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",
|