Reubencf commited on
Commit
aea1786
·
1 Parent(s): 94c2cc5

feat: Add Quiz Timer, Time Limit, and improve error handling

Browse files
Files changed (2) hide show
  1. app/components/QuizApp.tsx +100 -26
  2. mcp-server.js +8 -9
app/components/QuizApp.tsx CHANGED
@@ -37,6 +37,10 @@ export function QuizApp({ onClose, onMinimize, zIndex }: QuizAppProps) {
37
  const [tempPasskey, setTempPasskey] = useState('')
38
  const [isLocked, setIsLocked] = useState(true)
39
 
 
 
 
 
40
  const [questions, setQuestions] = useState<QuizQuestion[]>([])
41
  const [loading, setLoading] = useState(false)
42
  const [error, setError] = useState<string | null>(null)
@@ -82,10 +86,13 @@ export function QuizApp({ onClose, onMinimize, zIndex }: QuizAppProps) {
82
 
83
  // Parse content
84
  let quizData
 
 
85
  if (typeof quizFile.content === 'string') {
86
  try {
87
  quizData = JSON.parse(quizFile.content)
88
  } catch (e) {
 
89
  // Maybe it's double encoded or raw text
90
  quizData = quizFile.content
91
  }
@@ -94,16 +101,31 @@ export function QuizApp({ onClose, onMinimize, zIndex }: QuizAppProps) {
94
  }
95
 
96
  if (!quizData) {
97
- throw new Error('Quiz file is empty')
98
  }
99
 
 
 
100
  if (Array.isArray(quizData)) {
101
- setQuestions(quizData)
102
  } else if (quizData.questions) {
103
- setQuestions(quizData.questions)
 
 
 
 
 
104
  } else {
105
- throw new Error('Invalid quiz format')
106
  }
 
 
 
 
 
 
 
 
107
  } catch (err: any) {
108
  console.error('Error loading quiz:', err)
109
  setError(err.message || 'Could not load quiz.json')
@@ -112,6 +134,29 @@ export function QuizApp({ onClose, onMinimize, zIndex }: QuizAppProps) {
112
  }
113
  }
114
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  const handleOptionSelect = (option: string) => {
116
  const currentQ = questions[currentQuestionIndex]
117
  setAnswers(prev => ({
@@ -124,7 +169,7 @@ export function QuizApp({ onClose, onMinimize, zIndex }: QuizAppProps) {
124
  if (currentQuestionIndex < questions.length - 1) {
125
  setCurrentQuestionIndex(prev => prev + 1)
126
  } else {
127
- finishQuiz()
128
  }
129
  }
130
 
@@ -134,18 +179,25 @@ export function QuizApp({ onClose, onMinimize, zIndex }: QuizAppProps) {
134
  }
135
  }
136
 
137
- const finishQuiz = async () => {
138
  setSaving(true)
139
  try {
 
 
140
  // Save answers to secure storage
141
- const answersJson = JSON.stringify(
142
- Object.entries(answers).map(([id, answer]) => ({
143
  questionId: id,
144
  answer
145
  })),
146
- null,
147
- 2
148
- )
 
 
 
 
 
149
 
150
  await fetch('/api/data', {
151
  method: 'POST',
@@ -154,7 +206,7 @@ export function QuizApp({ onClose, onMinimize, zIndex }: QuizAppProps) {
154
  passkey,
155
  action: 'save_file',
156
  fileName: 'quiz_answers.json',
157
- content: answersJson
158
  })
159
  })
160
 
@@ -170,11 +222,16 @@ export function QuizApp({ onClose, onMinimize, zIndex }: QuizAppProps) {
170
 
171
  const handleCopyAnswers = async () => {
172
  const summary = questions.map((q, idx) => {
173
- const answer = answers[q.id]
174
  return `Q${idx + 1}: ${answer}`
175
  }).join('\n')
176
 
177
- const textToCopy = `Here are my quiz answers:\n\n${summary}\n\nPlease grade my quiz!`
 
 
 
 
 
178
 
179
  try {
180
  await navigator.clipboard.writeText(textToCopy)
@@ -210,11 +267,20 @@ export function QuizApp({ onClose, onMinimize, zIndex }: QuizAppProps) {
210
  </div>
211
  </div>
212
 
213
- {!isLocked && !loading && !error && !completed && (
214
- <div className="flex items-center gap-2 bg-white/50 px-3 py-1 rounded-md border border-black/5">
215
- <span className="text-xs font-medium text-gray-500">Question {currentQuestionIndex + 1} of {questions.length}</span>
216
- </div>
217
- )}
 
 
 
 
 
 
 
 
 
218
  </div>
219
 
220
  {/* Content */}
@@ -270,14 +336,22 @@ export function QuizApp({ onClose, onMinimize, zIndex }: QuizAppProps) {
270
  </div>
271
  ) : completed ? (
272
  <div className="flex flex-col items-center justify-center h-full max-w-md text-center animate-in fade-in zoom-in duration-300">
273
- <div className="w-20 h-20 bg-gradient-to-br from-green-400 to-emerald-500 rounded-full flex items-center justify-center mb-6 shadow-lg shadow-green-500/20">
274
- <Trophy size={40} weight="fill" className="text-white" />
 
 
 
 
275
  </div>
276
- <h2 className="text-2xl font-bold text-gray-900 mb-2">Quiz Completed!</h2>
 
 
277
  <p className="text-gray-500 mb-8">
278
- {hasEmbeddedAnswers
279
- ? "You've finished the quiz."
280
- : "Answers saved. Copy them below to get your grade from Claude."}
 
 
281
  </p>
282
 
283
  <div className="w-full space-y-3">
@@ -286,7 +360,7 @@ export function QuizApp({ onClose, onMinimize, zIndex }: QuizAppProps) {
286
  className="w-full py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-xl font-medium transition-colors shadow-lg shadow-blue-500/20 flex items-center justify-center gap-2"
287
  >
288
  {copySuccess ? <CheckCircle size={20} weight="fill" /> : <Copy size={20} weight="bold" />}
289
- {copySuccess ? 'Copied to Clipboard!' : 'Copy Answers for Claude'}
290
  </button>
291
 
292
  <button
 
37
  const [tempPasskey, setTempPasskey] = useState('')
38
  const [isLocked, setIsLocked] = useState(true)
39
 
40
+ const [timeLeft, setTimeLeft] = useState<number | null>(null)
41
+ const [timeLimit, setTimeLimit] = useState<number | null>(null)
42
+ const [startTime, setStartTime] = useState<number | null>(null)
43
+
44
  const [questions, setQuestions] = useState<QuizQuestion[]>([])
45
  const [loading, setLoading] = useState(false)
46
  const [error, setError] = useState<string | null>(null)
 
86
 
87
  // Parse content
88
  let quizData
89
+ console.log('Raw quiz content:', quizFile.content) // Debug log
90
+
91
  if (typeof quizFile.content === 'string') {
92
  try {
93
  quizData = JSON.parse(quizFile.content)
94
  } catch (e) {
95
+ console.error('JSON parse error:', e)
96
  // Maybe it's double encoded or raw text
97
  quizData = quizFile.content
98
  }
 
101
  }
102
 
103
  if (!quizData) {
104
+ throw new Error('Quiz file content is empty or invalid')
105
  }
106
 
107
+ // Handle different structures (array or object with questions)
108
+ let questionsArray = []
109
  if (Array.isArray(quizData)) {
110
+ questionsArray = quizData
111
  } else if (quizData.questions) {
112
+ questionsArray = quizData.questions
113
+ if (quizData.timeLimit) {
114
+ const limitInSeconds = quizData.timeLimit * 60
115
+ setTimeLimit(limitInSeconds)
116
+ setTimeLeft(limitInSeconds)
117
+ }
118
  } else {
119
+ throw new Error('Invalid quiz format: missing questions array')
120
  }
121
+
122
+ if (questionsArray.length === 0) {
123
+ throw new Error('Quiz has no questions')
124
+ }
125
+
126
+ setQuestions(questionsArray)
127
+ setStartTime(Date.now())
128
+
129
  } catch (err: any) {
130
  console.error('Error loading quiz:', err)
131
  setError(err.message || 'Could not load quiz.json')
 
134
  }
135
  }
136
 
137
+ // Timer effect
138
+ useEffect(() => {
139
+ if (!loading && !error && !completed && !isLocked && timeLeft !== null && timeLeft > 0) {
140
+ const timer = setInterval(() => {
141
+ setTimeLeft(prev => {
142
+ if (prev === null || prev <= 0) {
143
+ clearInterval(timer)
144
+ finishQuiz(true) // Time exceeded
145
+ return 0
146
+ }
147
+ return prev - 1
148
+ })
149
+ }, 1000)
150
+ return () => clearInterval(timer)
151
+ }
152
+ }, [loading, error, completed, isLocked, timeLeft])
153
+
154
+ const formatTime = (seconds: number) => {
155
+ const mins = Math.floor(seconds / 60)
156
+ const secs = seconds % 60
157
+ return `${mins}:${secs.toString().padStart(2, '0')}`
158
+ }
159
+
160
  const handleOptionSelect = (option: string) => {
161
  const currentQ = questions[currentQuestionIndex]
162
  setAnswers(prev => ({
 
169
  if (currentQuestionIndex < questions.length - 1) {
170
  setCurrentQuestionIndex(prev => prev + 1)
171
  } else {
172
+ finishQuiz(false)
173
  }
174
  }
175
 
 
179
  }
180
  }
181
 
182
+ const finishQuiz = async (timeExceeded: boolean = false) => {
183
  setSaving(true)
184
  try {
185
+ const timeTaken = startTime ? Math.floor((Date.now() - startTime) / 1000) : 0
186
+
187
  // Save answers to secure storage
188
+ const answersData = {
189
+ answers: Object.entries(answers).map(([id, answer]) => ({
190
  questionId: id,
191
  answer
192
  })),
193
+ metadata: {
194
+ completedAt: new Date().toISOString(),
195
+ timeTakenSeconds: timeTaken,
196
+ timeLimitSeconds: timeLimit,
197
+ timeExceeded: timeExceeded,
198
+ status: timeExceeded ? 'TIME_EXCEEDED' : 'COMPLETED'
199
+ }
200
+ }
201
 
202
  await fetch('/api/data', {
203
  method: 'POST',
 
206
  passkey,
207
  action: 'save_file',
208
  fileName: 'quiz_answers.json',
209
+ content: JSON.stringify(answersData, null, 2)
210
  })
211
  })
212
 
 
222
 
223
  const handleCopyAnswers = async () => {
224
  const summary = questions.map((q, idx) => {
225
+ const answer = answers[q.id] || '(Skipped)'
226
  return `Q${idx + 1}: ${answer}`
227
  }).join('\n')
228
 
229
+ // Add metadata to clipboard text for Claude
230
+ const metadata = timeLeft === 0
231
+ ? "\n\n⚠️ NOTE: I exceeded the time limit! Please give me zero points as per the rules."
232
+ : ""
233
+
234
+ const textToCopy = `Here are my quiz answers:\n\n${summary}${metadata}\n\nPlease grade my quiz!`
235
 
236
  try {
237
  await navigator.clipboard.writeText(textToCopy)
 
267
  </div>
268
  </div>
269
 
270
+ <div className="flex items-center gap-3">
271
+ {timeLeft !== null && !completed && !isLocked && (
272
+ <div className={`flex items-center gap-2 px-3 py-1 rounded-md border ${timeLeft < 60 ? 'bg-red-50 border-red-200 text-red-600' : 'bg-white/50 border-black/5 text-gray-600'}`}>
273
+ <ArrowClockwise size={14} className={timeLeft < 60 ? 'animate-spin' : ''} />
274
+ <span className="text-xs font-mono font-medium">{formatTime(timeLeft)}</span>
275
+ </div>
276
+ )}
277
+
278
+ {!isLocked && !loading && !error && !completed && (
279
+ <div className="flex items-center gap-2 bg-white/50 px-3 py-1 rounded-md border border-black/5">
280
+ <span className="text-xs font-medium text-gray-500">Question {currentQuestionIndex + 1} of {questions.length}</span>
281
+ </div>
282
+ )}
283
+ </div>
284
  </div>
285
 
286
  {/* Content */}
 
336
  </div>
337
  ) : completed ? (
338
  <div className="flex flex-col items-center justify-center h-full max-w-md text-center animate-in fade-in zoom-in duration-300">
339
+ <div className={`w-20 h-20 rounded-full flex items-center justify-center mb-6 shadow-lg ${timeLeft === 0 ? 'bg-red-500 shadow-red-500/20' : 'bg-gradient-to-br from-green-400 to-emerald-500 shadow-green-500/20'}`}>
340
+ {timeLeft === 0 ? (
341
+ <XCircle size={40} weight="fill" className="text-white" />
342
+ ) : (
343
+ <Trophy size={40} weight="fill" className="text-white" />
344
+ )}
345
  </div>
346
+ <h2 className="text-2xl font-bold text-gray-900 mb-2">
347
+ {timeLeft === 0 ? "Time's Up!" : "Quiz Completed!"}
348
+ </h2>
349
  <p className="text-gray-500 mb-8">
350
+ {timeLeft === 0
351
+ ? "You exceeded the time limit. Answers saved for evaluation."
352
+ : hasEmbeddedAnswers
353
+ ? "You've finished the quiz."
354
+ : "Answers saved. Ask Claude to grade them now."}
355
  </p>
356
 
357
  <div className="w-full space-y-3">
 
360
  className="w-full py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-xl font-medium transition-colors shadow-lg shadow-blue-500/20 flex items-center justify-center gap-2"
361
  >
362
  {copySuccess ? <CheckCircle size={20} weight="fill" /> : <Copy size={20} weight="bold" />}
363
+ {copySuccess ? 'Copied!' : 'Copy Answers for Claude'}
364
  </button>
365
 
366
  <button
mcp-server.js CHANGED
@@ -126,7 +126,6 @@ class ReubenOSMCPServer {
126
  properties: {
127
  title: { type: 'string', description: 'Quiz title' },
128
  description: { type: 'string', description: 'Quiz description' },
129
- timeLimit: { type: 'number', description: 'Time limit in minutes (optional)' },
130
  questions: {
131
  type: 'array',
132
  description: 'Array of questions',
@@ -134,20 +133,20 @@ class ReubenOSMCPServer {
134
  type: 'object',
135
  properties: {
136
  id: { type: 'string' },
 
137
  question: { type: 'string' },
138
- type: {
139
- type: 'string',
140
- enum: ['multiple-choice', 'true-false', 'short-answer']
141
- },
142
- options: {
143
- type: 'array',
144
- items: { type: 'string' }
145
- },
146
  points: { type: 'number' },
147
  },
148
  required: ['id', 'question', 'type'],
149
  },
150
  },
 
 
 
 
151
  },
152
  required: ['title', 'questions'],
153
  },
 
126
  properties: {
127
  title: { type: 'string', description: 'Quiz title' },
128
  description: { type: 'string', description: 'Quiz description' },
 
129
  questions: {
130
  type: 'array',
131
  description: 'Array of questions',
 
133
  type: 'object',
134
  properties: {
135
  id: { type: 'string' },
136
+ type: { type: 'string', enum: ['multiple_choice'] },
137
  question: { type: 'string' },
138
+ options: { type: 'array', items: { type: 'string' } },
139
+ correctAnswer: { type: 'string' },
140
+ explanation: { type: 'string' },
 
 
 
 
 
141
  points: { type: 'number' },
142
  },
143
  required: ['id', 'question', 'type'],
144
  },
145
  },
146
+ timeLimit: {
147
+ type: 'number',
148
+ description: 'Time limit in minutes for the quiz (optional)',
149
+ },
150
  },
151
  required: ['title', 'questions'],
152
  },