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:
parent
88912f83c7
commit
da8a717489
15 changed files with 289 additions and 98 deletions
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -58,15 +58,15 @@ ROWS = [
|
|||
("Hematology", "MCV", "88–123", "fL", "0–1 month", "Whole blood", UI, UI_URL),
|
||||
("Hematology", "MCV", "74–108", "fL", "3–6 months", "Whole blood", UI, UI_URL),
|
||||
("Hematology", "MCV", "70–85", "fL", "6 months–1 year", "Whole blood", UI, UI_URL),
|
||||
("Hematology", "Reticulocytes", "0.5–1.5", "%", "Child (higher in newborn)", "Whole blood", PEDI, PEDI_URL),
|
||||
("Hematology", "Reticulocytes", "0.5–1.5", "%", "All ages (higher in newborn)", "Whole blood", PEDI, PEDI_URL),
|
||||
# ── Chemistries ─────────────────────────────────────────────────────
|
||||
("Chemistries", "Sodium", "135–145", "mmol/L", ">10 days", "Plasma", PEDI, PEDI_URL),
|
||||
("Chemistries", "Potassium", "3.5–6.0", "mEq/L", "<10 days", "Plasma", UI, UI_URL),
|
||||
("Chemistries", "Potassium", "3.5–5.0", "mEq/L", ">10 days", "Plasma", UI, UI_URL),
|
||||
("Chemistries", "Chloride", "98–106", "mmol/L", "Child", "Plasma", PEDI, PEDI_URL),
|
||||
("Chemistries", "Bicarbonate (CO2)", "18–27", "mEq/L", "Child", "Plasma", UI, UI_URL),
|
||||
("Chemistries", "Bicarbonate", "22–26", "mmol/L", "Child", "Plasma", PEDI, PEDI_URL),
|
||||
("Chemistries", "Urea nitrogen (BUN)", "7–18 (urea 2.5–6.5 mmol/L)", "mg/dL", "Child", "Plasma", PEDI, PEDI_URL),
|
||||
("Chemistries", "Chloride", "98–106", "mmol/L", "All ages", "Plasma", PEDI, PEDI_URL),
|
||||
("Chemistries", "Bicarbonate (CO2)", "18–27", "mEq/L", "All ages", "Plasma", UI, UI_URL),
|
||||
("Chemistries", "Bicarbonate", "22–26", "mmol/L", "All ages", "Plasma", PEDI, PEDI_URL),
|
||||
("Chemistries", "Urea nitrogen (BUN)", "7–18 (urea 2.5–6.5 mmol/L)", "mg/dL", "All ages", "Plasma", PEDI, PEDI_URL),
|
||||
("Chemistries", "Creatinine", "0.2–0.9", "mg/dL", "Neonate", "Plasma", UI, UI_URL),
|
||||
("Chemistries", "Creatinine", "0.2–0.4", "mg/dL", "2–12 months", "Plasma", UI, UI_URL),
|
||||
("Chemistries", "Creatinine", "0.2–0.5", "mg/dL", "1–2 years", "Plasma", UI, UI_URL),
|
||||
|
|
@ -79,7 +79,7 @@ ROWS = [
|
|||
("Chemistries", "Calcium (total)", "8.7–10.5", "mg/dL", "31 days–1 year", "Plasma", UI, UI_URL),
|
||||
("Chemistries", "Calcium (total)", "8.8–10.6", "mg/dL", "1–6 years", "Plasma", UI, UI_URL),
|
||||
("Chemistries", "Calcium (ionized)", "4.2–5.9", "mg/dL", "Neonate", "Plasma", UI, UI_URL),
|
||||
("Chemistries", "Magnesium", "1.6–2.4 (0.7–1.0 mmol/L)", "mg/dL", "Child", "Plasma", PEDI, PEDI_URL),
|
||||
("Chemistries", "Magnesium", "1.6–2.4 (0.7–1.0 mmol/L)", "mg/dL", "All ages", "Plasma", PEDI, PEDI_URL),
|
||||
("Chemistries", "Phosphate", "4.2–9.0", "mg/dL", "Newborn–11 months", "Plasma", UI, UI_URL),
|
||||
("Chemistries", "Phosphate", "3.2–6.3", "mg/dL", "12 months–15 years", "Plasma", UI, UI_URL),
|
||||
("Chemistries", "Albumin", "2.8–4.4", "g/dL", "0–4 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", "3–5 days", "Serum", UI, UI_URL),
|
||||
("Chemistries", "Bilirubin (total)", "<1.0", "mg/dL", "1 month–adult", "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", "75–316 (M) / 48–406 (F)", "U/L", "1–30 days", "Plasma", UI, UI_URL),
|
||||
("Chemistries", "Alkaline phosphatase", "82–383 (M) / 124–341 (F)", "U/L", "31 days–1 year", "Plasma", UI, UI_URL),
|
||||
|
|
@ -106,28 +106,28 @@ ROWS = [
|
|||
("Acid-base / gases", "pCO2 (arterial)", "30–40", "mmHg", "0–18 years", "Arterial blood", UI, UI_URL),
|
||||
("Acid-base / gases", "pO2 (arterial)", "60–80", "mmHg", "0–1 month", "Arterial blood", UI, UI_URL),
|
||||
("Acid-base / gases", "pO2 (arterial)", "80–100", "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", "11–14", "seconds", "Child", "Plasma", PEDI, PEDI_URL),
|
||||
("Coagulation", "aPTT", "25–35", "seconds", "Child", "Plasma", PEDI, PEDI_URL),
|
||||
("Coagulation", "INR", "0.9–1.2", "", "Child", "Plasma", PEDI, PEDI_URL),
|
||||
("Coagulation", "Fibrinogen", "150–400 (1.5–4.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", "11–14", "seconds", "All ages", "Plasma", PEDI, PEDI_URL),
|
||||
("Coagulation", "aPTT", "25–35", "seconds", "All ages", "Plasma", PEDI, PEDI_URL),
|
||||
("Coagulation", "INR", "0.9–1.2", "", "All ages", "Plasma", PEDI, PEDI_URL),
|
||||
("Coagulation", "Fibrinogen", "150–400 (1.5–4.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", "15–40 (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.5–5.0", "mU/L", "Child (higher in neonate)", "Plasma", PEDI, PEDI_URL),
|
||||
("Endocrine", "Free T4", "12–22", "pmol/L", "Child", "Plasma", PEDI, PEDI_URL),
|
||||
("Endocrine", "Cortisol (08:00)", "170–500", "nmol/L", "Child", "Plasma", PEDI, PEDI_URL),
|
||||
("Endocrine", "TSH", "0.5–5.0", "mU/L", "All ages (higher in neonates)", "Plasma", PEDI, PEDI_URL),
|
||||
("Endocrine", "Free T4", "12–22", "pmol/L", "All ages", "Plasma", PEDI, PEDI_URL),
|
||||
("Endocrine", "Cortisol (08:00)", "170–500", "nmol/L", "All ages", "Plasma", PEDI, PEDI_URL),
|
||||
# ── Urine ────────────────────────────────────────────────────────────
|
||||
("Urine", "Specific gravity", "1.001–1.035", "", "Child", "Urine", PEDI, PEDI_URL),
|
||||
("Urine", "Specific gravity", "1.001–1.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:
|
||||
|
|
|
|||
50
backend/scripts/tag_prep_quizzes.py
Normal file
50
backend/scripts/tag_prep_quizzes.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
"""Tag questions by their PREP source quiz and retire any leftover PREP categories.
|
||||
|
||||
Run after the tag→category 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())
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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' }))
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
|
|
|
|||
|
|
@ -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' }}>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
|
|
@ -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 }}>
|
||||
|
|
|
|||
Loading…
Reference in a new issue