Mock Exam Refinements: Tag Follow, Fullscreen, Leaderboard Labels, Admin UX & Section-Accurate Question Generation
This article covers a batch of refinements to the Forem mock exam feature — fixing tag follow persistence, improving the fullscreen flow, showing proper set labels on the leaderboard, cleaning up admin UX, and fixing a critical bug in how questions are distributed across sets based on sections_config.
Table of Contents
- Tag Follow Persistence + Follow Button UI
- Remove Tick Mark from Set Cards
- Leaderboard: Proper Set Labels
- Fullscreen: Direct Entry from Instruction Modal
- Better Loading Spinner
- Admin: Supported Tags Only in Dropdown
- Admin: Remove Refresh Button
- Question Generation: Section-Accurate Distribution
- Fix Timer Reset on Navigation
- Remove "Press A/B" Labels from Options
1. Tag Follow Persistence + Follow Button UI
Problem: On the exam listing page, followed tags didn't persist — the exam showed in the compact list even when the tag was followed. Clicking the tag toggled it visually, but refreshing reverted it. The tag chip also didn't look like a proper follow button.
Fix:
- Pass
user_signed_infrom the backend so the frontend can gate follow actions behind auth. - Pass the follow body as an object (not pre-stringified) so Forem's
requestutility handles serialization properly. - Replace the
#tagchip with a pill-shaped follow/unfollow button.
Backend: app/controllers/mock_exams_controller.rb
Add user_signed_in to the listing JSON response (around line 27–31):
render json: {
templates: @mock_exam_templates.map { |t| template_json(t) },
followed_tags: followed,
user_signed_in: current_user.present?,
}
Frontend: app/javascript/mockExams/MockExamListing.jsx
Full file contents:
import { h } from 'preact';
import { useState, useEffect, useMemo, useCallback } from 'preact/hooks';
import { request } from '@utilities/http';
export function MockExamListing() {
const [templates, setTemplates] = useState([]);
const [followedTags, setFollowedTags] = useState([]);
const [loading, setLoading] = useState(true);
const [userSignedIn, setUserSignedIn] = useState(false);
useEffect(() => {
request('/mock_exams.json')
.then((res) => res.json())
.then((data) => {
setTemplates(data.templates || []);
setFollowedTags(data.followed_tags || []);
setUserSignedIn(data.user_signed_in || false);
setLoading(false);
})
.catch(() => setLoading(false));
}, []);
const followedNames = useMemo(
() => followedTags.map((t) => t.name),
[followedTags],
);
const toggleTag = useCallback((tagObj) => {
if (!userSignedIn) {
window.location.href = '/enter';
return;
}
setFollowedTags((prev) => {
const isFollowed = prev.some((t) => t.name === tagObj.name);
const updated = isFollowed
? prev.filter((t) => t.name !== tagObj.name)
: [...prev, tagObj];
// Fire-and-forget follow/unfollow via Forem's API
request('/follows', {
method: 'POST',
body: { followable_type: 'Tag', followable_id: tagObj.id, verb: isFollowed ? 'unfollow' : 'follow' },
}).catch(() => {});
return updated;
});
}, [userSignedIn]);
const { following, others } = useMemo(() => {
const f = [];
const o = [];
templates.forEach((t) => {
const tags = t.tag_list || [];
const isFollowed = tags.some((tag) => followedNames.includes(tag.name));
if (isFollowed) {
f.push(t);
} else {
o.push(t);
}
});
return { following: f, others: o };
}, [templates, followedNames]);
if (loading) {
return (
<div class="crayons-card p-6 flex justify-center">
<div class="crayons-loading" aria-label="Loading mock exams..." />
</div>
);
}
if (templates.length === 0) {
return (
<div class="crayons-card p-6 text-center">
<h2 class="crayons-title mb-2">Mock Exams</h2>
<p class="color-secondary">No mock exams available yet. Check back soon!</p>
</div>
);
}
return (
<div>
<div class="flex items-center justify-between mb-4 flex-wrap gap-2">
<h1 class="crayons-title">Mock Exams</h1>
</div>
{/* Following — large featured cards */}
{following.length > 0 && (
<div class="mb-6">
<h2 class="fw-bold mb-3" style={{ fontSize: '1.1rem' }}>Following</h2>
<div class="grid gap-4" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))' }}>
{following.map((t) => (
<FeaturedCard key={t.id} template={t} followedNames={followedNames} onToggleTag={toggleTag} />
))}
</div>
</div>
)}
{/* All Exams — compact rows */}
{others.length > 0 && (
<div>
<h2 class="fw-bold mb-3" style={{ fontSize: '1.1rem' }}>All Exams</h2>
<div class="flex flex-col gap-2">
{others.map((t) => (
<CompactRow key={t.id} template={t} followedNames={followedNames} onToggleTag={toggleTag} />
))}
</div>
</div>
)}
</div>
);
}
function TagChip({ tag, isFollowed, onToggle }) {
return (
<button
class={`c-btn c-btn--s ${isFollowed ? 'c-btn--primary' : 'c-btn--secondary'}`}
style={{
padding: '2px 10px',
borderRadius: '20px',
fontSize: '0.75rem',
minHeight: '28px',
gap: '4px',
display: 'inline-flex',
alignItems: 'center',
whiteSpace: 'nowrap',
transition: 'all 0.15s',
}}
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onToggle(tag); }}
title={isFollowed ? 'Unfollow this tag' : 'Follow this tag'}
>
<span style={{ fontSize: '0.7rem' }}>{isFollowed ? '✓' : '+'}</span>
#{tag.name.replace(/_/g, ' ')}
</button>
);
}
function FeaturedCard({ template: t, followedNames, onToggleTag }) {
return (
<a
href={`/mock_exams/${t.slug}`}
class="crayons-card p-4 block"
style={{
textDecoration: 'none', color: 'inherit',
transition: 'box-shadow 0.15s ease, transform 0.15s ease',
border: '1px solid var(--card-border)',
}}
onMouseEnter={(e) => { e.currentTarget.style.boxShadow = '0 4px 12px rgba(0,0,0,0.1)'; e.currentTarget.style.transform = 'translateY(-2px)'; }}
onMouseLeave={(e) => { e.currentTarget.style.boxShadow = 'none'; e.currentTarget.style.transform = 'none'; }}
>
<div class="flex items-start justify-between mb-2">
<h3 class="fw-bold fs-l" style={{ lineHeight: '1.3' }}>{t.title}</h3>
</div>
{t.description && (
<p class="color-secondary fs-s mb-3" style={{ lineHeight: '1.5' }}>
{t.description.length > 100 ? `${t.description.slice(0, 100)}...` : t.description}
</p>
)}
<div class="flex flex-wrap gap-2 mb-3">
{(t.tag_list || []).map((tag) => (
<TagChip key={tag.name} tag={tag} isFollowed={followedNames.includes(tag.name)} onToggle={onToggleTag} />
))}
{t.published_sets_count > 0 && (
<span class="crayons-tag crayons-tag--monochrome">
{t.published_sets_count} sets
</span>
)}
</div>
<div style={{ borderTop: '1px solid var(--card-border)', paddingTop: '8px' }}>
<div class="flex justify-between fs-s color-secondary">
<span title="Questions">📋 {t.total_questions}q</span>
<span title="Duration">⏱ {t.duration_minutes}m</span>
<span title="Marks">+{t.marks_per_correct} / −{t.negative_marks_per_wrong}</span>
</div>
</div>
</a>
);
}
function CompactRow({ template: t, followedNames, onToggleTag }) {
return (
<div
class="flex items-center justify-between flex-wrap"
style={{
padding: '10px 16px',
border: '1px solid var(--card-border)',
borderRadius: '8px',
background: 'var(--card-bg)',
gap: '12px',
}}
>
<div style={{ flex: '1 1 auto', minWidth: 0 }}>
<a href={`/mock_exams/${t.slug}`} class="fw-bold fs-s"
style={{ textDecoration: 'none', color: 'inherit' }}>
{t.title}
</a>
<div class="flex items-center gap-2 mt-1">
{(t.tag_list || []).map((tag) => (
<TagChip key={tag.name} tag={tag} isFollowed={followedNames.includes(tag.name)} onToggle={onToggleTag} />
))}
<span class="fs-xs color-secondary">📋 {t.total_questions}q</span>
<span class="fs-xs color-secondary">⏱ {t.duration_minutes}m</span>
{t.published_sets_count > 0 && (
<span class="fs-xs color-secondary">{t.published_sets_count} sets</span>
)}
</div>
</div>
</div>
);
}
2. Remove Tick Mark from Set Cards
Problem: Set cards on the detail page had a radio/checkmark circle that served no real purpose.
Fix: Remove the circle span and leftover paddingLeft: 28px from child rows.
Frontend: app/javascript/mockExams/MockExamDetail.jsx
In the set card rendering section, replace the old <div class="flex items-center justify-between mb-2"> block that contained the circle/checkmark with:
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<span class="fw-bold">{s.label}</span>
</div>
<div class="flex items-center gap-2">
<DifficultyBadge difficulty={s.difficulty} />
<span class="fs-s color-secondary">
{s.question_count}q
</span>
</div>
</div>
Also remove paddingLeft: '28px' from the attempts count row and the score stats row:
{/* Before: style={{ paddingLeft: '28px' }} — now removed */}
<div class="flex items-center justify-between">
{/* Before: style={{ paddingLeft: '28px', marginTop: '8px', ... }} — now just marginTop */}
{ua && (
<div style={{ marginTop: '8px', paddingTop: '8px', borderTop: '1px solid var(--card-border)' }}>
3. Leaderboard: Proper Set Labels
Problem: The leaderboard showed generic "Set 1", "Set 2" labels instead of the actual set names (date-hash based labels).
Fix: Add set_label to the leaderboard API response and use it in the frontend dropdown and table.
Backend: app/controllers/mock_exams_controller.rb
In the build_leaderboard_entries method, add set_label to each entry:
attempts.map do |attempt|
{
attempt_id: attempt.id,
user_id: attempt.user_id,
username: attempt.user.username,
name: attempt.user.name.presence || attempt.user.username,
profile_image: attempt.user.profile_image_90,
total_score: attempt.total_score,
max_possible_score: attempt.max_possible_score,
accuracy_percent: attempt.accuracy_percent,
pool_set: attempt.pool_set,
set_label: attempt.pool_set ? @template.set_label(attempt.pool_set) : nil,
time_taken_seconds: if attempt.submitted_at && attempt.started_at
(attempt.submitted_at - attempt.started_at).to_i
end
}
end
Frontend: app/javascript/mockExams/components/ExamLeaderboard.jsx
Full file contents:
import { h } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import { request } from '@utilities/http';
const TIME_FILTERS = [
{ key: 'all', label: 'All Time' },
{ key: 'month', label: 'This Month' },
{ key: 'week', label: 'This Week' },
];
export function ExamLeaderboard({ slug, currentUserId, currentAttemptId, sets }) {
const [entries, setEntries] = useState([]);
const [loading, setLoading] = useState(true);
const [timeFilter, setTimeFilter] = useState('all');
const [setFilter, setSetFilter] = useState('');
useEffect(() => {
setLoading(true);
let url = `/mock_exams/${slug}/leaderboard.json?filter=${timeFilter}`;
if (setFilter) url += `&set=${setFilter}`;
request(url)
.then((res) => res.json())
.then((data) => {
setEntries(data.entries || []);
setLoading(false);
})
.catch(() => setLoading(false));
}, [slug, timeFilter, setFilter]);
if (loading) {
return (
<div class="crayons-card p-6 flex justify-center">
<div class="crayons-loading" aria-label="Loading leaderboard..." />
</div>
);
}
const availableSets = sets || [];
return (
<div class="crayons-card p-6">
<div class="flex items-center justify-between mb-4 flex-wrap gap-2">
<h3 class="crayons-subtitle-2">Leaderboard</h3>
<div class="flex gap-2 flex-wrap">
{availableSets.length > 0 && (
<select
class="crayons-select fs-s"
value={setFilter}
onChange={(e) => setSetFilter(e.target.value)}
style={{ minWidth: '100px' }}
>
<option value="">All Sets</option>
{availableSets.map((s) => (
<option key={s.set_number} value={s.set_number}>
{s.label || `Set ${s.set_number}`}
</option>
))}
</select>
)}
<div class="flex gap-1">
{TIME_FILTERS.map((f) => (
<button
key={f.key}
class={`c-btn c-btn--s ${
timeFilter === f.key ? 'c-btn--primary' : 'c-btn--secondary'
}`}
onClick={() => setTimeFilter(f.key)}
>
{f.label}
</button>
))}
</div>
</div>
</div>
{entries.length === 0 ? (
<p class="color-secondary text-center">
No attempts yet for this period.
</p>
) : (
<table class="crayons-table" style={{ width: '100%' }}>
<thead>
<tr>
<th style={{ width: '50px' }}>#</th>
<th>Name</th>
<th>Set</th>
<th>Score</th>
<th>Accuracy</th>
<th>Time</th>
</tr>
</thead>
<tbody>
{entries.map((entry, i) => {
const isCurrentUser = entry.user_id === currentUserId;
return (
<tr
key={entry.attempt_id}
style={{
background: isCurrentUser
? 'var(--accent-brand-a10)' : 'transparent',
fontWeight: isCurrentUser ? 'bold' : 'normal',
}}
>
<td><RankBadge rank={i + 1} /></td>
<td>
<div class="flex items-center gap-2">
{entry.profile_image && (
<img
src={entry.profile_image}
alt=""
style={{
width: '24px', height: '24px', borderRadius: '50%',
}}
loading="lazy"
/>
)}
<span>
{entry.name || entry.username}
{isCurrentUser && (
<span class="fs-xs color-secondary"> (you)</span>
)}
</span>
</div>
</td>
<td class="fs-s">
{entry.set_label || (entry.pool_set ? `Set ${entry.pool_set}` : 'Random')}
</td>
<td>{entry.total_score} / {entry.max_possible_score}</td>
<td>{entry.accuracy_percent}%</td>
<td>{formatTime(entry.time_taken_seconds)}</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
);
}
function RankBadge({ rank }) {
const medals = { 1: '🥇', 2: '🥈', 3: '🥉' };
if (medals[rank]) {
return <span style={{ fontSize: '1.2rem' }}>{medals[rank]}</span>;
}
return <span class="color-secondary">{rank}</span>;
}
function formatTime(seconds) {
if (!seconds) return '—';
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}m ${s}s`;
}
4. Fullscreen: Direct Entry from Instruction Modal
Problem: After clicking "Full Screen Mode" in the instruction modal, the user was redirected to the exam page where a second "Click anywhere to enter fullscreen" overlay appeared. This required two clicks.
Fix: Enter fullscreen immediately within the button click handler (which satisfies the browser's user gesture requirement), then navigate. Remove the fullscreen prompt overlay from MockExamInterface.
Frontend: app/javascript/mockExams/MockExamDetail.jsx
Replace the handleConfirmStart function:
const handleConfirmStart = async (goFullscreen) => {
setStarting(true);
setError(null);
// Enter fullscreen immediately within user gesture context
if (goFullscreen && typeof document.documentElement.requestFullscreen === 'function') {
try {
await document.documentElement.requestFullscreen();
} catch {
// silently ignore if fullscreen fails
}
}
try {
const payload = pendingPoolSet ? { pool_set: pendingPoolSet } : {};
const res = await request(`/mock_exams/${slug}/attempts`, {
method: 'POST',
body: JSON.stringify(payload),
});
if (res.ok) {
const data = await res.json();
const url = data.redirect_to || `/mock_exams/${slug}/attempts/${data.id}`;
window.location.href = url;
} else {
const errData = await res.json();
setError(errData.errors?.[0] || errData.error || 'Failed to start exam');
setStarting(false);
// Exit fullscreen on error
if (document.fullscreenElement) document.exitFullscreen().catch(() => {});
}
} catch {
setError('Network error. Please try again.');
setStarting(false);
if (document.fullscreenElement) document.exitFullscreen().catch(() => {});
}
};
Frontend: app/javascript/mockExams/MockExamInterface.jsx
Three changes:
1. Initialize isFullscreen from current state (line 21):
const [isFullscreen, setIsFullscreen] = useState(!!document.fullscreenElement);
2. Remove showFullscreenPrompt state — delete the line:
// DELETE THIS LINE:
const [showFullscreenPrompt, setShowFullscreenPrompt] = useState(false);
3. Remove the mode=fullscreen URL param handling — delete the block inside the data-load useEffect:
// DELETE THIS BLOCK:
// Show fullscreen prompt if requested from detail page
const params = new URLSearchParams(window.location.search);
if (params.get('mode') === 'fullscreen' && typeof document.documentElement.requestFullscreen === 'function') {
setShowFullscreenPrompt(true);
}
4. Remove the fullscreen prompt overlay — delete the entire {showFullscreenPrompt && ...} block (the "Click anywhere to enter fullscreen" overlay with the Skip button).
5. Better Loading Spinner
Problem: The "Preparing your exam..." spinner in the instruction modal was small and hard to see.
Fix: Replace the small crayons-loading with a larger custom spinning ring and prominent text.
Frontend: app/javascript/mockExams/MockExamDetail.jsx
In the instruction modal, replace the loading section:
{starting ? (
<div class="flex flex-col items-center gap-4 p-6">
<div style={{
width: '48px', height: '48px',
border: '4px solid var(--card-border)',
borderTopColor: 'var(--accent-brand)',
borderRadius: '50%',
animation: 'spin 0.8s linear infinite',
}} />
<p class="fw-bold fs-l" style={{ color: 'var(--body-color)' }}>Preparing your exam...</p>
<p class="color-secondary fs-s">Please wait while we set things up</p>
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
</div>
) : (
6. Admin: Supported Tags Only in Dropdown
Problem: The admin tag dropdown showed ALL tags from the database — most are irrelevant to mock exams.
Fix: Filter to only show tags that are marked as "supported" for the current subforem.
Backend: app/controllers/admin/mock_exam_templates_controller.rb
Replace the set_tags method:
def set_tags
subforem = RequestStore.store[:subforem]
if subforem
supported_ids = subforem.tag_relationships.where(supported: true).pluck(:tag_id)
@tags = Tag.where(id: supported_ids).order(:name)
else
@tags = Tag.where(supported: true).order(:name)
end
end
7. Admin: Remove Refresh Button
Problem: The "Refresh Pool" button on the admin show page was redundant.
Fix: Remove the button and simplify the associated JavaScript.
View: app/views/admin/mock_exam_templates/show.html.erb
Remove the button_to "Refresh Pool" line from the question pool section. The section should now only contain:
<div class="flex gap-2 items-end mb-4">
<div>
<label class="crayons-field__label fs-s" for="sets_count">Sets to generate</label>
<input type="number" id="sets_count" value="3" min="1" max="20"
style="width: 70px;" class="crayons-textfield" />
</div>
<%= button_to "Generate Pool", generate_pool_admin_mock_exam_template_path(@template),
method: :post, class: "c-btn c-btn--primary",
data: { confirm: "Generate new question sets via AI. Proceed?" },
form: { id: "generate-pool-form" } %>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
var setsInput = document.getElementById('sets_count');
var form = document.getElementById('generate-pool-form');
if (form) {
form.addEventListener('submit', function() {
var hidden = form.querySelector('input[name="sets_count"]');
if (!hidden) {
hidden = document.createElement('input');
hidden.type = 'hidden';
hidden.name = 'sets_count';
form.appendChild(hidden);
}
hidden.value = setsInput.value;
});
}
});
</script>
8. Question Generation: Section-Accurate Distribution
Problem (Critical): The GeneratePoolWorker used flat idx / total_per_exam to assign questions to sets. But the AI generator returns questions grouped by section (all Polity first, then all History, etc.). This caused sets to get uneven section distributions — e.g., Set 1 might get 60 Polity questions instead of the intended 30 Polity + 20 History + 10 Geography.
Fix: Group generated questions by section_name, then distribute each section's questions evenly across sets using the exact counts from sections_config.
Backend: app/workers/mock_exams/generate_pool_worker.rb
Full file contents:
module MockExams
class GeneratePoolWorker
include Sidekiq::Job
sidekiq_options queue: :low_priority, lock: :until_executing, on_conflict: :replace
def perform(template_id, sets_count = 3)
return unless Ai::Base::DEFAULT_KEY.present?
template = MockExamTemplate.find_by(id: template_id)
return unless template
Rails.logger.info("MockExams::GeneratePoolWorker: Starting pool generation for template #{template_id}")
next_set = (template.pool_questions.maximum(:pool_set) || 0) + 1
sections_config = template.sections_config
generator = Ai::MockExamQuestionGenerator.new(template)
questions_data = generator.generate_pool(sets_count: sets_count)
# Group generated questions by section_name
questions_by_section = questions_data.group_by { |q| q["section_name"] }
# Build each set with the exact section counts from sections_config
sets = Array.new(sets_count) { [] }
sections_config.each do |section|
section_name = section["name"]
section_count = section["count"]
available = questions_by_section[section_name] || []
sets_count.times do |set_idx|
start_idx = set_idx * section_count
batch = available[start_idx, section_count] || []
sets[set_idx].concat(batch)
end
end
created = 0
sets.each_with_index do |set_questions, set_idx|
set_number = next_set + set_idx
set_questions.each_with_index do |q_data, pos|
MockExamQuestion.create!(
mock_exam_template: template,
mock_exam_attempt: nil,
pool_set: set_number,
section_name: q_data["section_name"],
position: pos + 1,
question_type: q_data["question_type"] || "knowledge",
question_text: q_data["question_text"],
question_format: :text,
options: q_data["options"],
correct_option_key: q_data["correct_option_key"],
explanation: q_data["explanation"],
solution_steps: q_data["solution_steps"],
difficulty: q_data["difficulty"] || "medium",
topic_tags: q_data["topic_tags"] || [],
ai_generation_metadata: {
model: Ai::Base::DEFAULT_LITE_MODEL,
generated_at: Time.current.iso8601,
pool_generation: true,
pool_set: set_number,
},
)
created += 1
rescue ActiveRecord::RecordInvalid => e
Rails.logger.warn("MockExams::GeneratePoolWorker: Skipped invalid question — #{e.message}")
end
end
Rails.logger.info(
"MockExams::GeneratePoolWorker: Pool complete — #{created} questions created for template #{template_id}",
)
MockExams::TranslatePoolWorker.perform_async(template_id)
end
end
end
How it works
Given a sections_config like:
[
{ "name": "Polity", "count": 30, "type": "knowledge", "topics": ["Constitution", "Governance"] },
{ "name": "History", "count": 20, "type": "knowledge", "topics": ["Modern India", "Ancient India"] },
{ "name": "Geography", "count": 10, "type": "knowledge", "topics": ["Physical", "Human"] }
]
And sets_count = 3, the AI generator produces:
- 90 Polity questions (30 × 3)
- 60 History questions (20 × 3)
- 30 Geography questions (10 × 3)
The worker now correctly distributes:
- Set 1: Polity[0..29] + History[0..19] + Geography[0..9] = 60 questions
- Set 2: Polity[30..59] + History[20..39] + Geography[10..19] = 60 questions
- Set 3: Polity[60..89] + History[40..59] + Geography[20..29] = 60 questions
Each set gets exactly the right number of questions per section, and the QuestionPalette component (which groups by section_name matched against sections_config) will show the correct number of section blocks.
9. Fix Timer Reset on Navigation
Problem: The exam timer reset to the original value every time the user clicked "Next" or "Prev" to navigate between questions.
Root cause: ExamTimer included onTimeUp in its useEffect dependency array. Since onTimeUp is a callback whose reference changes on re-renders (due to its own dependencies like submitting), the effect re-ran on every navigation — calling setRemaining(timeRemainingSeconds) again and restarting the interval.
Fix: Store onTimeUp in a ref so the interval always calls the latest version, but the effect only re-runs when timeRemainingSeconds actually changes.
Frontend: app/javascript/mockExams/components/ExamTimer.jsx
Full file contents:
import { h } from 'preact';
import { useState, useEffect, useRef } from 'preact/hooks';
export function ExamTimer({ timeRemainingSeconds, onTimeUp }) {
const [remaining, setRemaining] = useState(timeRemainingSeconds);
const intervalRef = useRef(null);
const onTimeUpRef = useRef(onTimeUp);
// Keep the callback ref current without re-triggering the interval
useEffect(() => {
onTimeUpRef.current = onTimeUp;
}, [onTimeUp]);
useEffect(() => {
setRemaining(timeRemainingSeconds);
intervalRef.current = setInterval(() => {
setRemaining((prev) => {
if (prev <= 1) {
clearInterval(intervalRef.current);
onTimeUpRef.current?.();
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(intervalRef.current);
}, [timeRemainingSeconds]);
const hours = Math.floor(remaining / 3600);
const minutes = Math.floor((remaining % 3600) / 60);
const seconds = remaining % 60;
const pad = (n) => String(n).padStart(2, '0');
const isLow = remaining < 300; // < 5 min
const isCritical = remaining < 60; // < 1 min
return (
<div
class="fw-bold fs-l"
style={{
color: isCritical
? 'var(--accent-danger)'
: isLow
? 'var(--accent-warning)'
: 'inherit',
fontVariantNumeric: 'tabular-nums',
}}
>
{hours > 0 && `${pad(hours)}:`}
{pad(minutes)}:{pad(seconds)}
</div>
);
}
10. Remove "Press A/B" Labels from Options
Problem: Each option button displayed a small "Press A", "Press B" etc. label. This cluttered the UI.
Fix: Remove the hint <span> from QuestionDisplay.jsx. Keyboard shortcuts (A/B/C/D) still work — they are handled by the keydown listener in MockExamInterface.
Frontend: app/javascript/mockExams/components/QuestionDisplay.jsx
Delete the following block from inside the option <button> (was between the option text <div> and the review checkmark spans):
// DELETE THIS BLOCK:
{!isReview && (
<span style={{
fontSize: '0.65rem', color: 'var(--body-color)',
opacity: 0.4, fontFamily: 'monospace',
flexShrink: 0,
}} class="hide-mobile">
Press {opt.key}
</span>
)}
Summary of All Files Changed
| File | Change |
|---|---|
app/controllers/mock_exams_controller.rb |
Added user_signed_in to listing JSON; added set_label to leaderboard entries |
app/javascript/mockExams/MockExamListing.jsx |
Auth-gated follow, proper body format, pill-shaped TagChip |
app/javascript/mockExams/MockExamDetail.jsx |
Removed tick mark from set cards, fullscreen before navigation, better spinner |
app/javascript/mockExams/MockExamInterface.jsx |
Removed fullscreen prompt overlay, correct initial isFullscreen state |
app/javascript/mockExams/components/ExamLeaderboard.jsx |
Use set_label in dropdown and table rows |
app/javascript/mockExams/components/ExamTimer.jsx |
Fixed timer reset on navigation by storing onTimeUp in a ref |
app/javascript/mockExams/components/QuestionDisplay.jsx |
Removed "Press A/B/C/D" hint labels from options |
app/controllers/admin/mock_exam_templates_controller.rb |
Filter tags to subforem-supported only |
app/views/admin/mock_exam_templates/show.html.erb |
Removed Refresh Pool button and simplified JS |
app/workers/mock_exams/generate_pool_worker.rb |
Section-accurate question distribution across sets |
Top comments (0)