DEV Community

Murari Kumar
Murari Kumar

Posted on

Tag Follow

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

  1. Tag Follow Persistence + Follow Button UI
  2. Remove Tick Mark from Set Cards
  3. Leaderboard: Proper Set Labels
  4. Fullscreen: Direct Entry from Instruction Modal
  5. Better Loading Spinner
  6. Admin: Supported Tags Only in Dropdown
  7. Admin: Remove Refresh Button
  8. Question Generation: Section-Accurate Distribution
  9. Fix Timer Reset on Navigation
  10. 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_in from the backend so the frontend can gate follow actions behind auth.
  • Pass the follow body as an object (not pre-stringified) so Forem's request utility handles serialization properly.
  • Replace the #tag chip 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?,
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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">
Enter fullscreen mode Exit fullscreen mode
{/* Before: style={{ paddingLeft: '28px', marginTop: '8px', ... }} — now just marginTop */}
{ua && (
  <div style={{ marginTop: '8px', paddingTop: '8px', borderTop: '1px solid var(--card-border)' }}>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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`;
}
Enter fullscreen mode Exit fullscreen mode

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(() => {});
  }
};
Enter fullscreen mode Exit fullscreen mode

Frontend: app/javascript/mockExams/MockExamInterface.jsx

Three changes:

1. Initialize isFullscreen from current state (line 21):

const [isFullscreen, setIsFullscreen] = useState(!!document.fullscreenElement);
Enter fullscreen mode Exit fullscreen mode

2. Remove showFullscreenPrompt state — delete the line:

// DELETE THIS LINE:
const [showFullscreenPrompt, setShowFullscreenPrompt] = useState(false);
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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>
) : (
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"] }
]
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
)}
Enter fullscreen mode Exit fullscreen mode

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)