Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 31 additions & 13 deletions src/components/calculator/CaratEstimator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,14 @@ export function CaratEstimator() {
const [length, setLength] = useState('');
const [width, setWidth] = useState('');
const [depth, setDepth] = useState('');
const [sg, setSg] = useState('3.52');
const [sgSource, setSgSource] = useState<'preset' | 'custom'>('preset');
const [sgPreset, setSgPreset] = useState('3.52');
const [sgCustom, setSgCustom] = useState('');
const [shape, setShape] = useState<Shape>('round-brilliant');

// Get the effective SG value
const sg = sgSource === 'custom' ? sgCustom : sgPreset;

const result = useMemo(() => {
const l = parseFloat(length);
const w = parseFloat(width);
Expand Down Expand Up @@ -140,24 +145,37 @@ export function CaratEstimator() {
</label>
<select
id="sg-select"
value={sg}
onChange={(e) => setSg(e.target.value)}
value={sgSource === 'custom' ? 'custom' : sgPreset}
onChange={(e) => {
if (e.target.value === 'custom') {
setSgSource('custom');
} else {
setSgSource('preset');
setSgPreset(e.target.value);
}
}}
className="w-full px-3 py-2 rounded-lg border border-slate-300 focus:outline-none focus:ring-2 focus:ring-crystal-500"
>
{COMMON_SG.map(item => (
<option key={item.value} value={item.value}>{item.label}</option>
))}
<option value="custom">Custom SG...</option>
</select>
<input
type="number"
step="0.01"
min="1"
max="10"
value={sg}
onChange={(e) => setSg(e.target.value)}
placeholder="Custom SG"
className="w-full mt-2 px-3 py-2 rounded-lg border border-slate-300 focus:outline-none focus:ring-2 focus:ring-crystal-500 text-sm"
/>
{/* Custom SG input - only shown when "Custom" is selected */}
{sgSource === 'custom' && (
<input
type="number"
step="0.01"
min="1"
max="10"
value={sgCustom}
onChange={(e) => setSgCustom(e.target.value)}
placeholder="Enter custom SG (e.g., 3.50)"
aria-label="Custom specific gravity value"
autoFocus
className="w-full mt-2 px-3 py-2 rounded-lg border border-slate-300 focus:outline-none focus:ring-2 focus:ring-crystal-500 text-sm"
/>
)}
</div>
</div>

Expand Down
35 changes: 35 additions & 0 deletions src/components/quiz/Exam.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,16 @@ export function Exam({
const unansweredCount = questions.length - answeredCount;
const isFlagged = isQuestionFlagged(currentQuestion.id);

// Calculate pacing indicator
const timeUsed = timeLimit - timeRemaining;
const suggestedTimePerQuestion = timeLimit / questions.length;
const expectedQuestionIndex = Math.floor(timeUsed / suggestedTimePerQuestion);
const actualQuestionIndex = state.currentIndex;
// Consider "on pace" if within 1 question of expected, or ahead
const isPacingOk = actualQuestionIndex >= expectedQuestionIndex - 1;
// Calculate how many questions ahead/behind
const pacingDiff = actualQuestionIndex - expectedQuestionIndex;

return (
<div className="max-w-4xl mx-auto space-y-4">
{/* Header bar */}
Expand All @@ -102,6 +112,31 @@ export function Exam({
isExpired={isTimeExpired}
/>

{/* Pacing indicator - hidden on mobile, shows on sm+ */}
<div
className={cn(
'hidden sm:flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium',
isPacingOk
? 'bg-emerald-100 text-emerald-700'
: 'bg-amber-100 text-amber-700'
)}
title={pacingDiff >= 0
? `${pacingDiff} question${pacingDiff !== 1 ? 's' : ''} ahead of suggested pace`
: `${Math.abs(pacingDiff)} question${Math.abs(pacingDiff) !== 1 ? 's' : ''} behind suggested pace`
}
>
{isPacingOk ? (
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : (
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)}
<span>{isPacingOk ? 'On pace' : 'Behind pace'}</span>
</div>

{/* Quick stats */}
<QuestionNavCompact
current={state.currentIndex + 1}
Expand Down
28 changes: 22 additions & 6 deletions src/components/quiz/ExamResults.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export function ExamResults({
onNewExam,
}: ExamResultsProps) {
const [showReview, setShowReview] = useState(false);
const [reviewFilter, setReviewFilter] = useState<'all' | 'incorrect' | 'correct'>('all');
const [reviewFilter, setReviewFilter] = useState<'all' | 'incorrect' | 'correct' | 'flagged'>('all');

const grade = getGrade(results.percentage);
const feedback = getFeedback(results.percentage);
Expand All @@ -42,10 +42,12 @@ export function ExamResults({
const filteredResults = results.results.filter(r => {
if (reviewFilter === 'incorrect') return !r.isCorrect;
if (reviewFilter === 'correct') return r.isCorrect;
if (reviewFilter === 'flagged') return r.wasFlagged;
return true;
});

const incorrectCount = results.results.filter(r => !r.isCorrect).length;
const flaggedCount = results.results.filter(r => r.wasFlagged).length;

if (showReview) {
return (
Expand All @@ -64,11 +66,12 @@ export function ExamResults({
</div>

{/* Filter tabs */}
<div className="flex gap-2 border-b border-slate-200 pb-2">
<div className="flex flex-wrap gap-2 border-b border-slate-200 pb-2">
{[
{ value: 'all' as const, label: `All (${results.results.length})` },
{ value: 'incorrect' as const, label: `Incorrect (${incorrectCount})` },
{ value: 'correct' as const, label: `Correct (${results.score})` },
...(flaggedCount > 0 ? [{ value: 'flagged' as const, label: `Flagged (${flaggedCount})` }] : []),
].map(tab => (
<button
key={tab.value}
Expand Down Expand Up @@ -284,7 +287,7 @@ interface QuestionReviewCardProps {
}

function QuestionReviewCard({ result, questionNumber }: QuestionReviewCardProps) {
const { question, userAnswer, isCorrect } = result;
const { question, userAnswer, isCorrect, wasFlagged } = result;
const correctAnswer = Array.isArray(question.correctAnswer)
? question.correctAnswer[0]
: question.correctAnswer;
Expand All @@ -303,9 +306,22 @@ function QuestionReviewCard({ result, questionNumber }: QuestionReviewCardProps)
isCorrect ? 'bg-emerald-50' : 'bg-red-50'
)}
>
<span className="text-sm font-medium text-slate-700">
Question {questionNumber}
</span>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-slate-700">
Question {questionNumber}
</span>
{wasFlagged && (
<span
className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-amber-100 text-amber-700"
title="You flagged this question for review"
>
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
<path fillRule="evenodd" d="M3 6a3 3 0 013-3h10a1 1 0 01.8 1.6L14.25 8l2.55 3.4A1 1 0 0116 13H6a1 1 0 00-1 1v3a1 1 0 11-2 0V6z" clipRule="evenodd" />
</svg>
Flagged
</span>
)}
</div>
<span
className={cn(
'px-2 py-1 text-xs font-medium rounded-full',
Expand Down
2 changes: 2 additions & 0 deletions src/lib/quiz/question-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,8 @@ export interface QuestionResult {
isCorrect: boolean;
/** Time taken to answer in milliseconds */
timeTaken?: number;
/** Whether the question was flagged for review (exam mode) */
wasFlagged?: boolean;
}

/** Final results after completing a quiz */
Expand Down
2 changes: 2 additions & 0 deletions src/lib/quiz/scoring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,15 @@ export function calculateResults(
for (const question of state.questions) {
const userAnswer = state.answers.get(question.id) || null;
const isCorrect = userAnswer !== null && checkAnswer(question, userAnswer);
const wasFlagged = state.flaggedQuestions.has(question.id);

if (isCorrect) score++;

results.push({
question,
userAnswer,
isCorrect,
wasFlagged,
});
}

Expand Down
Loading