feat: PREP as tags, stats toggle, comments redesign, quizzes CTA

PREP provenance becomes keyword tags tied to the source quizzes; PREP question categories retired. Response statistics get a persistent hide/show toggle. Comments redesigned with avatars, badges and a cleaner compose box. Create Custom Test is now a prominent card on the Quizzes page only. Hierarchy conversion gains pediatric sub-specialties and disease children. 96 frontend tests pass.
This commit is contained in:
Daniel 2026-09-09 01:30:20 +02:00
parent 88912f83c7
commit da8a717489
15 changed files with 289 additions and 98 deletions

View file

@ -147,23 +147,60 @@ def main():
system_by_name = {name: get_category(name).id for name in SYSTEMS}
print(f"Canonical systems: {len(SYSTEMS)}")
# Level 2: 'Pediatric X' subject tags become children of the matching system.
def pediatric_sub(tag):
name = tag.strip()
if not name.casefold().startswith("pediatric "):
return None
base = name[9:].strip()
system = canonical_system(base) or canonical_system(name)
if not system or system == "General Pediatrics":
return None
return system, name
sub_ids: dict[str, int] = {}
for qid, tag in subject_rows:
pair = pediatric_sub(tag)
if pair:
system, name = pair
sub_ids.setdefault(name, get_category(name, system_by_name[system]).id)
# Level 3: diseases under the systems where the pair is actually used.
pair_usage = defaultdict(int)
for qid, names in diseases.items():
systems_for_q = {canonical_system(t) for t in subjects.get(qid, [])
if canonical_system(t) and canonical_system(t) != "General Pediatrics"} or {"General Pediatrics"}
for name in names:
for system in systems_for_q:
pair_usage[(system, name)] += 1
disease_ids: dict[tuple, int] = {}
for (system, name), used in pair_usage.items():
if used >= 3:
disease_ids[(system, name)] = get_category(name, system_by_name[system]).id
print(f"Pediatric subs: {len(sub_ids)}; disease children: {len(disease_ids)}")
changed, links_added, skipped = 0, 0, 0
for qid in question_ids:
specific = sorted({canonical_system(tag) for tag in subjects.get(qid, [])
if canonical_system(tag) and canonical_system(tag) != "General Pediatrics"})
if specific:
system_names = specific
elif subjects.get(qid) or diseases.get(qid):
system_names = ["General Pediatrics"]
systems = specific
else:
skipped += 1
continue
# Systems first: diseases become children in a later pass once the taxonomy settles.
primary = system_by_name[system_names[0]]
extras = {system_by_name[name] for name in system_names}
# Untagged and Pediatrics-only questions fall back to General Pediatrics;
# PREP categories are being retired so nothing is left dangling.
systems = ["General Pediatrics"]
if not (subjects.get(qid) or diseases.get(qid)):
skipped += 1
systems = ["General Pediatrics"]
subs = sorted({pediatric_sub(tag)[1] for tag in subjects.get(qid, []) if pediatric_sub(tag)})
system_ids = [system_by_name[name] for name in systems]
sub_category_ids = [sub_ids[name] for name in subs]
disease_children = [disease_ids[(system, disease)] for disease in sorted(set(diseases.get(qid, [])))
for system in systems if (system, disease) in disease_ids]
# Primary: deepest specific subject (pediatric sub, else system); diseases stay as extras.
primary = sub_category_ids[0] if sub_category_ids else system_ids[0]
extras = set(system_ids + sub_category_ids + disease_children)
extras.discard(primary)
if old_primary.get(qid) is not None:
extras.add(old_primary[qid])
if old_primary.get(qid) != primary:
db.query(Question).filter(Question.id == qid).update({"question_category_id": primary})
changed += 1
@ -171,9 +208,29 @@ def main():
for cid in sorted(extras - existing):
db.add(QuestionCategoryLink(question_id=qid, category_id=cid))
links_added += 1
# PREP provenance becomes a keyword tag; the question categories are retired.
prep_categories = db.query(QuestionCategory).filter(QuestionCategory.name.ilike("prep %")).all()
prep_tagged = 0
for category in prep_categories:
linked = {row[0] for row in db.query(QuestionCategoryLink.question_id).filter_by(category_id=category.id).all()}
linked |= {row[0] for row in db.query(Question.id).filter(Question.question_category_id == category.id).all()}
linked.discard(None)
if not linked:
db.delete(category)
continue
db.execute(text("INSERT INTO question_tags (name, type) VALUES (:name, 'keyword') "
"ON CONFLICT (LOWER(name), type) DO NOTHING"), {"name": category.name})
tag_id = db.execute(text("SELECT id FROM question_tags WHERE LOWER(name) = LOWER(:name) AND type = 'keyword'"),
{"name": category.name}).scalar_one()
for qid in linked:
db.execute(text("INSERT INTO question_tag_links (question_id, tag_id) VALUES (:q, :t) "
"ON CONFLICT DO NOTHING"), {"q": qid, "t": tag_id})
prep_tagged += len(linked)
db.delete(category)
db.commit()
print(f"Reassigned primary for {changed} questions; added {links_added} extra links; skipped {skipped}; "
f"{db.query(QuestionCategory).count()} categories total.")
print(f"Reassigned primary for {changed} questions; added {links_added} extra links; "
f"tagged {prep_tagged} question-links across {len(prep_categories)} retired PREP categories; "
f"skipped {skipped}; {db.query(QuestionCategory).count()} categories total.")
finally:
db.close()

View file

@ -58,15 +58,15 @@ ROWS = [
("Hematology", "MCV", "88123", "fL", "01 month", "Whole blood", UI, UI_URL),
("Hematology", "MCV", "74108", "fL", "36 months", "Whole blood", UI, UI_URL),
("Hematology", "MCV", "7085", "fL", "6 months1 year", "Whole blood", UI, UI_URL),
("Hematology", "Reticulocytes", "0.51.5", "%", "Child (higher in newborn)", "Whole blood", PEDI, PEDI_URL),
("Hematology", "Reticulocytes", "0.51.5", "%", "All ages (higher in newborn)", "Whole blood", PEDI, PEDI_URL),
# ── Chemistries ─────────────────────────────────────────────────────
("Chemistries", "Sodium", "135145", "mmol/L", ">10 days", "Plasma", PEDI, PEDI_URL),
("Chemistries", "Potassium", "3.56.0", "mEq/L", "<10 days", "Plasma", UI, UI_URL),
("Chemistries", "Potassium", "3.55.0", "mEq/L", ">10 days", "Plasma", UI, UI_URL),
("Chemistries", "Chloride", "98106", "mmol/L", "Child", "Plasma", PEDI, PEDI_URL),
("Chemistries", "Bicarbonate (CO2)", "1827", "mEq/L", "Child", "Plasma", UI, UI_URL),
("Chemistries", "Bicarbonate", "2226", "mmol/L", "Child", "Plasma", PEDI, PEDI_URL),
("Chemistries", "Urea nitrogen (BUN)", "718 (urea 2.56.5 mmol/L)", "mg/dL", "Child", "Plasma", PEDI, PEDI_URL),
("Chemistries", "Chloride", "98106", "mmol/L", "All ages", "Plasma", PEDI, PEDI_URL),
("Chemistries", "Bicarbonate (CO2)", "1827", "mEq/L", "All ages", "Plasma", UI, UI_URL),
("Chemistries", "Bicarbonate", "2226", "mmol/L", "All ages", "Plasma", PEDI, PEDI_URL),
("Chemistries", "Urea nitrogen (BUN)", "718 (urea 2.56.5 mmol/L)", "mg/dL", "All ages", "Plasma", PEDI, PEDI_URL),
("Chemistries", "Creatinine", "0.20.9", "mg/dL", "Neonate", "Plasma", UI, UI_URL),
("Chemistries", "Creatinine", "0.20.4", "mg/dL", "212 months", "Plasma", UI, UI_URL),
("Chemistries", "Creatinine", "0.20.5", "mg/dL", "12 years", "Plasma", UI, UI_URL),
@ -79,7 +79,7 @@ ROWS = [
("Chemistries", "Calcium (total)", "8.710.5", "mg/dL", "31 days1 year", "Plasma", UI, UI_URL),
("Chemistries", "Calcium (total)", "8.810.6", "mg/dL", "16 years", "Plasma", UI, UI_URL),
("Chemistries", "Calcium (ionized)", "4.25.9", "mg/dL", "Neonate", "Plasma", UI, UI_URL),
("Chemistries", "Magnesium", "1.62.4 (0.71.0 mmol/L)", "mg/dL", "Child", "Plasma", PEDI, PEDI_URL),
("Chemistries", "Magnesium", "1.62.4 (0.71.0 mmol/L)", "mg/dL", "All ages", "Plasma", PEDI, PEDI_URL),
("Chemistries", "Phosphate", "4.29.0", "mg/dL", "Newborn11 months", "Plasma", UI, UI_URL),
("Chemistries", "Phosphate", "3.26.3", "mg/dL", "12 months15 years", "Plasma", UI, UI_URL),
("Chemistries", "Albumin", "2.84.4", "g/dL", "04 days", "Plasma", UI, UI_URL),
@ -91,8 +91,8 @@ ROWS = [
("Chemistries", "Bilirubin (total)", "≤7.0", "mg/dL", "Term, 48 h", "Serum", UI, UI_URL),
("Chemistries", "Bilirubin (total)", "≤12.0", "mg/dL", "35 days", "Serum", UI, UI_URL),
("Chemistries", "Bilirubin (total)", "<1.0", "mg/dL", "1 monthadult", "Serum", UI, UI_URL),
("Chemistries", "ALT", "<40", "U/L", "Child", "Plasma", PEDI, PEDI_URL),
("Chemistries", "AST", "<45", "U/L", "Child (higher in infants)", "Plasma", PEDI, PEDI_URL),
("Chemistries", "ALT", "<40", "U/L", "All ages", "Plasma", PEDI, PEDI_URL),
("Chemistries", "AST", "<45", "U/L", "All ages (higher in infants)", "Plasma", PEDI, PEDI_URL),
("Chemistries", "GGT", "<55", "U/L", "Child (high in neonates)", "Plasma", PEDI, PEDI_URL),
("Chemistries", "Alkaline phosphatase", "75316 (M) / 48406 (F)", "U/L", "130 days", "Plasma", UI, UI_URL),
("Chemistries", "Alkaline phosphatase", "82383 (M) / 124341 (F)", "U/L", "31 days1 year", "Plasma", UI, UI_URL),
@ -106,28 +106,28 @@ ROWS = [
("Acid-base / gases", "pCO2 (arterial)", "3040", "mmHg", "018 years", "Arterial blood", UI, UI_URL),
("Acid-base / gases", "pO2 (arterial)", "6080", "mmHg", "01 month", "Arterial blood", UI, UI_URL),
("Acid-base / gases", "pO2 (arterial)", "80100", "mmHg", ">1 month", "Arterial blood", UI, UI_URL),
("Acid-base / gases", "Base excess", "2 to +2", "mmol/L", "Child", "Blood gas", PEDI, PEDI_URL),
("Acid-base / gases", "Lactate", "<2.0", "mmol/L", "Child", "Blood", PEDI, PEDI_URL),
("Acid-base / gases", "Base excess", "2 to +2", "mmol/L", "All ages", "Blood gas", PEDI, PEDI_URL),
("Acid-base / gases", "Lactate", "<2.0", "mmol/L", "All ages", "Blood", PEDI, PEDI_URL),
# ── Coagulation ──────────────────────────────────────────────────────
("Coagulation", "PT", "1114", "seconds", "Child", "Plasma", PEDI, PEDI_URL),
("Coagulation", "aPTT", "2535", "seconds", "Child", "Plasma", PEDI, PEDI_URL),
("Coagulation", "INR", "0.91.2", "", "Child", "Plasma", PEDI, PEDI_URL),
("Coagulation", "Fibrinogen", "150400 (1.54.0 g/L)", "mg/dL", "Child", "Plasma", PEDI, PEDI_URL),
("Coagulation", "D-dimer", "<0.5", "mg/L", "Child (assay-dependent)", "Plasma", PEDI, PEDI_URL),
("Coagulation", "PT", "1114", "seconds", "All ages", "Plasma", PEDI, PEDI_URL),
("Coagulation", "aPTT", "2535", "seconds", "All ages", "Plasma", PEDI, PEDI_URL),
("Coagulation", "INR", "0.91.2", "", "All ages", "Plasma", PEDI, PEDI_URL),
("Coagulation", "Fibrinogen", "150400 (1.54.0 g/L)", "mg/dL", "All ages", "Plasma", PEDI, PEDI_URL),
("Coagulation", "D-dimer", "<0.5", "mg/L", "All ages (assay-dependent)", "Plasma", PEDI, PEDI_URL),
# ── Inflammatory ─────────────────────────────────────────────────────
("Inflammatory", "CRP", "<10 (<1.0 mg/dL)", "mg/L", "Child", "Serum", PEDI, PEDI_URL),
("Inflammatory", "Procalcitonin", "<0.5", "ng/mL", "Child", "Serum", PEDI, PEDI_URL),
("Inflammatory", "ESR", "<15", "mm/h", "Child (varies)", "Blood", PEDI, PEDI_URL),
("Inflammatory", "CRP", "<10 (<1.0 mg/dL)", "mg/L", "All ages", "Serum", PEDI, PEDI_URL),
("Inflammatory", "Procalcitonin", "<0.5", "ng/mL", "All ages", "Serum", PEDI, PEDI_URL),
("Inflammatory", "ESR", "<15", "mm/h", "All ages (varies)", "Blood", PEDI, PEDI_URL),
# ── CSF ──────────────────────────────────────────────────────────────
("CSF", "CSF white cell count", "<5 (neonate up to ~20)", "/mm³", "Child vs neonate", "CSF", CSF_SRC, CSF_URL),
("CSF", "CSF protein", "1540 (neonate up to ~100)", "mg/dL", "Child vs neonate", "CSF", CSF_SRC, CSF_URL),
("CSF", "CSF glucose", "≥2/3 of plasma glucose", "", "Child", "CSF", CSF_SRC, CSF_URL),
("CSF", "CSF glucose", "≥2/3 of plasma glucose", "", "All ages", "CSF", CSF_SRC, CSF_URL),
# ── Endocrine ────────────────────────────────────────────────────────
("Endocrine", "TSH", "0.55.0", "mU/L", "Child (higher in neonate)", "Plasma", PEDI, PEDI_URL),
("Endocrine", "Free T4", "1222", "pmol/L", "Child", "Plasma", PEDI, PEDI_URL),
("Endocrine", "Cortisol (08:00)", "170500", "nmol/L", "Child", "Plasma", PEDI, PEDI_URL),
("Endocrine", "TSH", "0.55.0", "mU/L", "All ages (higher in neonates)", "Plasma", PEDI, PEDI_URL),
("Endocrine", "Free T4", "1222", "pmol/L", "All ages", "Plasma", PEDI, PEDI_URL),
("Endocrine", "Cortisol (08:00)", "170500", "nmol/L", "All ages", "Plasma", PEDI, PEDI_URL),
# ── Urine ────────────────────────────────────────────────────────────
("Urine", "Specific gravity", "1.0011.035", "", "Child", "Urine", PEDI, PEDI_URL),
("Urine", "Specific gravity", "1.0011.035", "", "All ages", "Urine", PEDI, PEDI_URL),
]
@ -136,6 +136,13 @@ def main():
try:
updated_by = db.query(User.id).filter(User.role.in_(["admin", "moderator"])).order_by(User.id).first()
updated_by = updated_by[0] if updated_by else None
# Normalize vague population labels on existing rows to match the new seed data.
db.query(LabReference).filter(LabReference.age_group == "Child").update({"age_group": "All ages"}, synchronize_session=False)
db.query(LabReference).filter(LabReference.age_group == "Child (higher in infants)").update({"age_group": "All ages (higher in infants)"}, synchronize_session=False)
db.query(LabReference).filter(LabReference.age_group == "Child (higher in newborn)").update({"age_group": "All ages (higher in newborn)"}, synchronize_session=False)
db.query(LabReference).filter(LabReference.age_group == "Child (higher in neonate)").update({"age_group": "All ages (higher in neonates)"}, synchronize_session=False)
db.commit()
existing = {(row.group, row.name, row.age_group) for row in db.query(LabReference).all()}
added = 0
for group, name, ref, units, age, specimen, source, url in ROWS:

View file

@ -0,0 +1,50 @@
"""Tag questions by their PREP source quiz and retire any leftover PREP categories.
Run after the tagcategory conversion: PREP provenance moves from categories to
keyword tags named after the PREP quiz (e.g. 'PREP 2020').
"""
import re
import sys
from sqlalchemy import text
from app.database import SessionLocal
from app.models.question_category import QuestionCategory
from app.models.quiz import Quiz
def main():
db = SessionLocal()
try:
prep_quizzes = db.query(Quiz).filter(Quiz.title.ilike("%prep%")).all()
tagged = 0
for quiz in prep_quizzes:
year = re.search(r"\b(19|20)\d{2}\b", quiz.title or "")
tag_name = f"PREP {year.group(0)}" if year else (quiz.title or f"PREP {quiz.id}").strip()
db.execute(text("INSERT INTO question_tags (name, type) VALUES (:name, 'keyword') "
"ON CONFLICT (LOWER(name), type) DO NOTHING"), {"name": tag_name})
tag_id = db.execute(text("SELECT id FROM question_tags WHERE LOWER(name) = LOWER(:name) AND type = 'keyword'"),
{"name": tag_name}).scalar_one()
rows = db.execute(text("""
SELECT id FROM questions WHERE quiz_id = :quiz_id
UNION
SELECT question_id FROM quiz_question_links WHERE quiz_id = :quiz_id
"""), {"quiz_id": quiz.id}).all()
for row in rows:
db.execute(text("INSERT INTO question_tag_links (question_id, tag_id) VALUES (:q, :t) "
"ON CONFLICT DO NOTHING"), {"q": row[0], "t": tag_id})
tagged += 1
print(f"{tag_name}: {len(rows)} questions tagged")
# Retire any PREP categories that still exist.
leftovers = db.query(QuestionCategory).filter(QuestionCategory.name.ilike("prep %")).all()
for category in leftovers:
db.query(QuestionCategory).filter(QuestionCategory.id == category.id).delete()
db.commit()
print(f"Tagged {tagged} question-links across {len(prep_quizzes)} PREP quizzes; "
f"retired {len(leftovers)} leftover PREP categories.")
finally:
db.close()
if __name__ == "__main__":
sys.exit(main())

View file

@ -47,48 +47,55 @@ export default function CommentSection({ articleId, questionId }) {
try {
const res = await api.patch(`/comments/${id}`, { status })
setComments(prev => prev.map(c => c.id === id ? res.data : c))
} catch (err) { setError('Could not moderate comment') }
} catch { setError('Could not moderate comment') }
}
if (!loaded) return null
return (
<section className="comment-section" aria-label="Comments" data-testid="comment-section">
<h3>Comments {total > 0 && <span className="article-card-meta">({total})</span>}</h3>
<div className="comment-form">
<textarea className="input" rows={2} maxLength={2000} value={draft} onChange={e => setDraft(e.target.value)}
placeholder="Ask a question or add a note — visible to everyone after educator approval." aria-label="Comment text" />
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<div className="comment-heading">
<h3>Discussion{total > 0 ? ` · ${total}` : ''}</h3>
<span className="comment-subtitle">Visible to everyone after educator approval.</span>
</div>
<div className="comment-compose">
<textarea className="input comment-input" rows={2} maxLength={2000} value={draft} onChange={e => setDraft(e.target.value)}
placeholder="Ask a question or add a note…" aria-label="Comment text" />
<div className="comment-compose-footer">
<span className="comment-count">{draft.length}/2000</span>
{error && <span className="form-error" role="alert">{error}</span>}
<button className="btn btn-primary btn-sm" disabled={submitting || !draft.trim()} onClick={submit}>
{submitting ? 'Posting…' : 'Post comment'}
</button>
{error && <span className="form-error" role="alert">{error}</span>}
</div>
</div>
{comments.length === 0 ? (
<p className="article-card-meta">No comments yet.</p>
<p className="comment-empty">No comments yet start the discussion.</p>
) : (
<ul className="comment-list">
{comments.map(comment => (
<li key={comment.id} className="comment">
<div className="comment-meta">
<strong>{comment.author_name}</strong>
<span className="article-card-meta">{new Date(comment.created_at).toLocaleDateString()}
{comment.status === 'pending' && <em className="article-status-draft"> awaiting approval</em>}</span>
</div>
<div className="comment-content"><ReactMarkdown remarkPlugins={[remarkGfm]}>{comment.content}</ReactMarkdown></div>
{comment.can_moderate && (
<div style={{ display: 'flex', gap: 6 }}>
{comment.status !== 'approved' && <button className="btn btn-sm btn-primary" onClick={() => moderate(comment.id, 'approved')}>Approve</button>}
{comment.status !== 'rejected' && <button className="btn btn-sm btn-secondary" onClick={() => moderate(comment.id, 'rejected')}>Reject</button>}
<div className="comment-avatar" aria-hidden="true">{comment.author_name?.charAt(0) || '?'}</div>
<div className="comment-body">
<div className="comment-meta">
<strong>{comment.author_name}</strong>
<span>{new Date(comment.created_at).toLocaleDateString()}</span>
{comment.status === 'pending' && <em className="comment-badge">awaiting approval</em>}
</div>
)}
<div className="comment-content"><ReactMarkdown remarkPlugins={[remarkGfm]}>{comment.content}</ReactMarkdown></div>
{comment.can_moderate && (
<div className="comment-actions">
{comment.status !== 'approved' && <button className="btn btn-primary btn-sm" onClick={() => moderate(comment.id, 'approved')}>Approve</button>}
{comment.status !== 'rejected' && <button className="btn btn-secondary btn-sm" onClick={() => moderate(comment.id, 'rejected')}>Reject</button>}
</div>
)}
</div>
</li>
))}
</ul>
)}
{comments.length < total && (
<button className="btn btn-secondary btn-sm" onClick={() => load(offset + LIMIT)}>Load more</button>
<button className="btn btn-secondary btn-sm comment-load-more" onClick={() => load(offset + LIMIT)}>Load more</button>
)}
</section>
)

View file

@ -28,7 +28,7 @@ describe('comment section', () => {
it('posts a comment and prepends it', async () => {
render(<CommentSection questionId={5} />)
await screen.findByRole('heading', { name: /Comments/ })
await screen.findByRole('heading', { name: /Discussion/ })
await userEvent.type(screen.getByLabelText('Comment text'), 'New note')
await userEvent.click(screen.getByRole('button', { name: 'Post comment' }))
await waitFor(() => expect(api.post).toHaveBeenCalledWith('/comments', { question_id: 5, content: 'New note' }))

View file

@ -61,3 +61,11 @@
.quiz-lab-source, .quiz-lab-actions, .quiz-lab-cards { grid-column: 1 / -1; }
.quiz-lab-pop { grid-column: 1 / -1; }
}
.quiz-labs-sources { margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--border); display: flex; gap: 8px 14px; flex-wrap: wrap; align-items: baseline; font-size: .72rem; color: var(--text-muted); }
.quiz-labs-sources a { color: var(--primary); text-decoration: none; }
.quiz-labs-sources a:hover { text-decoration: underline; }
.quiz-lab-values { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.quiz-lab-age-line { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; padding: 3px 0; border-bottom: 1px dashed var(--border); }
.quiz-lab-age-line:last-child { border-bottom: none; }
.quiz-lab-age { color: var(--text-muted); font-size: .78rem; min-width: 130px; }
.quiz-lab-row { grid-template-columns: minmax(150px, 1fr) minmax(200px, 1.6fr); }

View file

@ -130,45 +130,67 @@ function LabValues() {
{(group === 'All' ? groups.slice(1) : [group]).map(groupName => {
const groupRows = ordered.filter(row => row.group === groupName)
if (!groupRows.length) return null
// One line per test; its age-specific values stack underneath.
const tests = []
for (const row of groupRows) {
const last = tests[tests.length - 1]
if (last && last.name === row.name) last.rows.push(row)
else tests.push({ name: row.name, rows: [row] })
}
return <section key={groupName} className="quiz-lab-group" aria-label={groupName}>
<h3 className="quiz-lab-group-title">{groupName}</h3>
<ul className="quiz-lab-rows">
{groupRows.map(row => <li key={row.id} className="quiz-lab-row">
{tests.map(test => <li key={test.name} className="quiz-lab-row">
<div className="quiz-lab-name">
<strong>{row.name}{!row.is_published && <em className="article-status-draft"> draft</em>}</strong>
<small>{row.specimen}</small>
<strong>{test.name}{test.rows.some(r => !r.is_published) && <em className="article-status-draft"> draft</em>}</strong>
<small>{test.rows[0].specimen}</small>
</div>
<div className="quiz-lab-range"><strong>{row.reference_range}</strong>{row.units && <span className="quiz-lab-units">{row.units}</span>}</div>
<div className="quiz-lab-pop">{row.age_group}</div>
<div className="quiz-lab-source">
{row.article_id ? (
<Link to={`/articles/${row.article_id}${row.article_section_id ? `?section=${row.article_section_id}` : ''}`}>
📖 {row.article_title || 'Article'}{row.article_section_title ? ` ${row.article_section_title}` : ''}
</Link>
) : row.source_url ? <a href={row.source_url} target="_blank" rel="noopener noreferrer">{row.source}</a> : <span>{row.source}</span>}
</div>
{(row.cards?.length > 0 || manage) && <div className="quiz-lab-cards">
{row.cards.map(card => (
<span key={card.card_id} className="quiz-lab-card">
<Link to={`/flashcards/${card.deck_id}/study`}>{card.front.slice(0, 40)}{card.front.length > 40 ? '…' : ''}</Link>
{manage && <button type="button" aria-label={`Unlink card ${card.card_id} from ${row.name}`} onClick={() => unlinkCard(row.id, card.card_id)}></button>}
</span>
<div className="quiz-lab-values">
{test.rows.map(row => (
<div key={row.id} className="quiz-lab-age-line">
<span className="quiz-lab-age">{row.age_group}</span>
<span className="quiz-lab-range"><strong>{row.reference_range}</strong>{row.units && <span className="quiz-lab-units">{row.units}</span>}</span>
{manage && <span className="quiz-lab-actions">
<button type="button" onClick={() => setForm({ ...row, source_url: row.source_url || '', article_id: row.article_id || '', article_section_id: row.article_section_id || '' })}>Edit</button>
{removeId === row.id ? <>
<button type="button" disabled={saving} onClick={() => remove(row.id)}>Confirm delete</button><button type="button" onClick={() => setRemoveId(null)}>Cancel</button>
</> : <button type="button" onClick={() => setRemoveId(row.id)}>Delete</button>}
</span>}
</div>
))}
{manage && <span className="quiz-lab-card-link">
<input aria-label={`Card ID to link to ${row.name}`} placeholder="Card ID" value={cardLink[row.id] || ''} onChange={e => setCardLink(prev => ({ ...prev, [row.id]: e.target.value }))} />
<button type="button" onClick={() => linkCard(row.id)}>Link card</button>
</span>}
</div>}
{manage && <div className="quiz-lab-actions">
<button type="button" onClick={() => setForm({ ...row, source_url: row.source_url || '', article_id: row.article_id || '', article_section_id: row.article_section_id || '' })}>Edit {row.name}</button>
{removeId === row.id ? <>
<button type="button" disabled={saving} onClick={() => remove(row.id)}>Confirm delete</button><button type="button" onClick={() => setRemoveId(null)}>Cancel</button>
</> : <button type="button" onClick={() => setRemoveId(row.id)}>Delete {row.name}</button>}
</div>}
</div>
{(() => {
const row = test.rows[0]
const cards = [...new Map(test.rows.flatMap(r => (r.cards || []).map(c => [c.card_id, c]))).values()]
if (!cards.length && !manage) return null
return <div className="quiz-lab-cards">
{cards.map(card => (
<span key={card.card_id} className="quiz-lab-card">
<Link to={`/flashcards/${card.deck_id}/study`}>{card.front.slice(0, 40)}{card.front.length > 40 ? '…' : ''}</Link>
{manage && <button type="button" aria-label={`Unlink card ${card.card_id} from ${test.name}`} onClick={() => unlinkCard(row.id, card.card_id)}></button>}
</span>
))}
{manage && <span className="quiz-lab-card-link">
<input aria-label={`Card ID to link to ${test.name}`} placeholder="Card ID" value={cardLink[row.id] || ''} onChange={e => setCardLink(prev => ({ ...prev, [row.id]: e.target.value }))} />
<button type="button" onClick={() => linkCard(row.id)}>Link card</button>
</span>}
</div>
})()}
</li>)}
</ul>
</section>
})}
<footer className="quiz-labs-sources">
<strong>Sources</strong>
{[...new Map(rows.map(row => [row.source, row])).values()].map(row => (
<a key={row.source} href={row.source_url || undefined} target="_blank" rel="noopener noreferrer">{row.source}</a>
))}
{[...new Map(rows.filter(row => row.article_id).map(row => [row.article_id + (row.article_section_id || ''), row])).values()].map(row => (
<Link key={`a${row.article_id}${row.article_section_id || ''}`} to={`/articles/${row.article_id}${row.article_section_id ? `?section=${row.article_section_id}` : ''}`}>
📖 {row.article_title}{row.article_section_title ? ` ${row.article_section_title}` : ''}
</Link>
))}
</footer>
</div>}
{form && manage && <form className="quiz-reference-form" onSubmit={save}>
<h3>{form.id ? 'Edit reference' : 'New reference'}</h3>

View file

@ -47,11 +47,22 @@
.article-content { padding: 16px; }
}
.comment-section { margin-top: 22px; border-top: 1px solid var(--border); padding-top: 14px; }
.comment-section h3 { margin: 0 0 10px; font-size: 1rem; }
.comment-form { display: flex; flex-direction: column; gap: 8px; margin-bottom: 12px; }
.comment-heading { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; margin-bottom: 10px; }
.comment-heading h3 { margin: 0; font-size: 1rem; }
.comment-subtitle { font-size: .74rem; color: var(--text-muted); }
.comment-compose { background: var(--input-bg); border: 1px solid var(--border); border-radius: 10px; padding: 10px; margin-bottom: 14px; }
.comment-input { width: 100%; border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; font-size: .88rem; resize: vertical; }
.comment-compose-footer { display: flex; align-items: center; gap: 10px; margin-top: 8px; }
.comment-count { margin-left: auto; font-size: .72rem; color: var(--text-muted); }
.comment-empty { color: var(--text-muted); font-size: .84rem; margin: 6px 0 0; }
.comment-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
.comment { border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; }
.comment-meta { display: flex; justify-content: space-between; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 4px; }
.comment-content { font-size: 0.88rem; }
.comment { display: flex; gap: 10px; padding: 12px; border: 1px solid var(--border); border-radius: 10px; background: var(--card-bg); }
.comment-avatar { width: 30px; height: 30px; border-radius: 50%; background: var(--primary-soft, #dcebfa); color: var(--primary); font-weight: 700; display: flex; align-items: center; justify-content: center; font-size: .9rem; flex-shrink: 0; }
.comment-body { flex: 1; min-width: 0; }
.comment-meta { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; margin-bottom: 4px; font-size: .84rem; }
.comment-meta span { color: var(--text-muted); font-size: .74rem; }
.comment-badge { font-style: normal; font-size: .66rem; font-weight: 700; text-transform: uppercase; color: #92400e; background: #fef3c7; padding: 1px 8px; border-radius: 10px; }
.comment-content { font-size: .88rem; }
.comment-content p { margin: 0 0 4px; }
.comment-section .comment .btn { margin-top: 6px; }
.comment-actions { display: flex; gap: 6px; margin-top: 8px; }
.comment-load-more { margin-top: 10px; }

View file

@ -68,11 +68,11 @@ export default function CustomQuizPage() {
<div className="custom-test">
<Link to="/quizzes"> Quizzes</Link>
<h1>Create Custom Test</h1>
<p>Choose questions from your bank, {user?.name || 'learner'}. Your test saves a fixed selection.</p>
<p>Choose questions from your bank, {user?.name || 'learner'}.</p>
<form onSubmit={submit} className="card">
<fieldset disabled={submitting}>
<legend>Categories</legend>
<p>Select any combination. Parent categories include all descendants; overlapping selections count once. No selection includes the whole bank.</p>
<p>Parent categories include all their subcategories.</p>
<div className="custom-test-categories">
{categories.map(cat => (
<label key={cat.id}>

View file

@ -828,7 +828,6 @@ export default function QuestionBankPage() {
return (
<div>
<Dialog {...dialogProps} />
<Link className="btn btn-primary" to="/quizzes/create" style={{ marginBottom: 16 }}>Create Custom Test</Link>
{/* Delete category dialog */}
{deletingCatId && (() => {
const cat = categories.find(c => c.id === deletingCatId)

View file

@ -188,7 +188,7 @@ describe('QuestionBankPage category hierarchy', () => {
api.get.mockImplementation(url => url === '/question-categories/' ? Promise.resolve({ data: cats }) : initialGet(url))
api.patch = vi.fn().mockResolvedValue({ data: {} })
renderPage()
expect(await screen.findByRole('link', { name: 'Create Custom Test' })).toHaveAttribute('href', '/quizzes/create')
expect(screen.queryByRole('link', { name: 'Create Custom Test' })).not.toBeInTheDocument()
await userEvent.click(await screen.findByRole('button', { name: 'Edit category Root' }))
const parent = screen.getByLabelText('Parent category')
expect([...parent.options].map(o => o.text)).toEqual(['No parent (root)', 'Other'])

View file

@ -412,6 +412,11 @@ export default function QuizPage() {
const [showReview, setShowReview] = useState(false)
const [responseStats, setResponseStats] = useState(null)
const [statsError, setStatsError] = useState('')
const [showStats, setShowStats] = useState(() => localStorage.getItem('pedshub_show_stats') !== '0')
const toggleStats = () => setShowStats(value => {
localStorage.setItem('pedshub_show_stats', value ? '0' : '1')
return !value
})
const [submitError, setSubmitError] = useState('')
const [resumeError, setResumeError] = useState('')
const [resumeRetry, setResumeRetry] = useState(0)
@ -1206,7 +1211,7 @@ const timerStarted = timeLeft !== null
speechRange={optionSpeechRange}
onRemoveHighlight={removeJoinedHighlight}
/>
{responseStats?.sample_size > 0 && responseStats.options?.[i] && <span className="quiz-response-stat">
{showStats && responseStats?.sample_size > 0 && responseStats.options?.[i] && <span className="quiz-response-stat">
<span className="quiz-response-track"><span style={{ width: `${responseStats.options[i].percentage}%` }} /></span>
<span>{responseStats.options[i].percentage}%</span><span>{responseStats.options[i].count}/{responseStats.sample_size}</span>
</span>}
@ -1232,7 +1237,12 @@ const timerStarted = timeLeft !== null
{isStudy && answers[current.id] && (
<>
<div className="quiz-review-tabs"><span>Preferred response</span>{current.page_reference && <span className="quiz-source-page">Source page {current.page_reference}</span>}</div>
{responseStats && <p className="quiz-stats-note">{responseStats.sample_size ? `Based on ${responseStats.sample_size} recorded answers` : 'No response statistics available yet'}</p>}
{responseStats && <p className="quiz-stats-note">
{showStats ? (responseStats.sample_size ? `Based on ${responseStats.sample_size} recorded answers` : 'No response statistics available yet') : 'Response statistics hidden'}
<button type="button" className="quiz-stats-toggle" aria-pressed={showStats} onClick={toggleStats}>
{showStats ? 'Hide stats' : 'Show stats'}
</button>
</p>}
{statsError && <p className="quiz-stats-note">{statsError}</p>}
{(current.explanation || current.explanation_image_path) && (
<div className="explanation" style={{ marginTop: 16, whiteSpace: 'pre-line' }}>

View file

@ -169,6 +169,18 @@ describe('quiz player', () => {
expect(screen.getByText('Distractor reason')).toBeInTheDocument()
})
it('lets the learner hide response statistics', async () => {
await begin()
fireEvent.keyDown(window, { key: '1' })
fireEvent.keyDown(window, { key: 'Enter' })
expect(await screen.findByText('8/10')).toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: 'Hide stats' }))
expect(screen.queryByText('8/10')).not.toBeInTheDocument()
expect(screen.getByText('Response statistics hidden')).toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: 'Show stats' }))
expect(await screen.findByText('8/10')).toBeInTheDocument()
})
it('never fetches response statistics in exam mode', async () => {
mode = 'exam'
await begin(false)

View file

@ -77,3 +77,5 @@
.question-reading ul { margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 4px; }
.question-reading a { color: var(--primary); font-size: .86rem; }
.quiz-option-explanation { display: block; width: 100%; margin-top: 6px; padding: 6px 10px; border-radius: 6px; background: var(--input-bg); border: 1px solid var(--border); font-size: .8rem; color: var(--text); text-align: left; }
.quiz-stats-toggle { margin-left: 8px; background: none; border: 1px solid var(--border); border-radius: 999px; padding: 2px 10px; font-size: .72rem; color: var(--text-muted); cursor: pointer; }
.quiz-stats-toggle:hover { color: var(--primary); border-color: var(--primary); }

View file

@ -406,7 +406,13 @@ export default function QuizzesPage() {
</a>
</div>
<Link className="btn btn-primary" to="/quizzes/create" style={{ marginBottom: 16 }}>Create Custom Test</Link>
<div className="card" style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, flexWrap: 'wrap', background: 'var(--primary-soft, #eef4fb)', border: '1px solid var(--primary)' }}>
<div>
<strong>Create Custom Test</strong>
<p style={{ margin: '2px 0 0', fontSize: '0.82rem', color: 'var(--text-muted)' }}>Pick systems and subcategories from the question bank and choose study or exam mode.</p>
</div>
<Link className="btn btn-primary" to="/quizzes/create">Create Custom Test</Link>
</div>
{/* Search bar */}
<div className="card" style={{ marginBottom: 16 }}>