diff --git a/backend/scripts/sanitize_tags.py b/backend/scripts/sanitize_tags.py
new file mode 100644
index 0000000..28045b1
--- /dev/null
+++ b/backend/scripts/sanitize_tags.py
@@ -0,0 +1,199 @@
+"""Clean the tag vocabulary so it can serve as a real category system.
+
+The tags were produced per-question by a model, so the same concept appears
+several times with different casing, punctuation and pluralisation
+("Absence Seizure" / "Absence Seizures", "Abo Incompatibility"), symptoms are
+filed as diseases, and a long tail of one-off keywords clutters every picker.
+
+What it does, in order:
+ 1. normalise names — collapse whitespace, fix medical acronym casing
+ 2. merge duplicates that share a canonical key, repointing links
+ 3. reclassify symptom-shaped entries from `disease` to `keyword`
+ 4. delete tags no question uses
+
+Idempotent, and a dry run by default:
+
+ docker compose exec backend python -m scripts.sanitize_tags # report
+ docker compose exec backend python -m scripts.sanitize_tags --apply # write
+"""
+import re
+import sys
+from collections import defaultdict
+
+from sqlalchemy import text as sa_text
+
+from app.database import SessionLocal
+
+# Rendered by title-casing, which mangles medical acronyms and genetics notation.
+ACRONYMS = {
+ "Abo": "ABO", "Adhd": "ADHD", "Aids": "AIDS", "Als": "ALS", "Aml": "AML",
+ "All": "ALL", "Ards": "ARDS", "Asd": "ASD", "Avsd": "AVSD", "Bpd": "BPD",
+ "Cf": "CF", "Chd": "CHD", "Cmv": "CMV", "Cns": "CNS", "Copd": "COPD",
+ "Csf": "CSF", "Ct": "CT", "Dka": "DKA", "Dmd": "DMD", "Dsd": "DSD",
+ "Ebv": "EBV", "Ecg": "ECG", "Eeg": "EEG", "Gerd": "GERD", "Gbs": "GBS",
+ "Hiv": "HIV", "Hsp": "HSP", "Hus": "HUS", "Iga": "IgA", "Igе": "IgE",
+ "Ige": "IgE", "Itp": "ITP", "Iugr": "IUGR", "Ivh": "IVH", "Jia": "JIA",
+ "Mri": "MRI", "Nec": "NEC", "Nsaid": "NSAID", "Ocd": "OCD", "Pda": "PDA",
+ "Ptsd": "PTSD", "Rsv": "RSV", "Sids": "SIDS", "Sle": "SLE", "Ssri": "SSRI",
+ "Tof": "TOF", "Uti": "UTI", "Vsd": "VSD", "Xx": "XX", "Xy": "XY", "Xyy": "XYY",
+ "Dna": "DNA", "Rna": "RNA", "Gi": "GI", "Icu": "ICU", "Nicu": "NICU", "Picu": "PICU",
+}
+
+# Presenting features, matched WHOLE — never as a substring.
+#
+# Substring matching cannot do this safely: "Whooping Cough" is pertussis and
+# "Rocky Mountain Spotted Fever" is a disease, yet both contain a symptom word.
+# Misfiling a diagnosis as a symptom is worse than an untidy vocabulary, so only
+# an exact name moves, and ambiguous terms ("seizure", "jaundice", "murmur")
+# are not listed at all — they name diagnoses as often as symptoms in paediatrics.
+SYMPTOM_NAMES = {
+ "abdominal distention", "abdominal distension", "abdominal pain",
+ "back pain", "chest pain", "constipation", "cyanosis", "diarrhea",
+ "diarrhoea", "dizziness", "dysphagia", "dyspnea", "dysuria",
+ "failure to thrive", "fatigue", "fever", "hematuria", "hepatomegaly",
+ "irritability", "joint pain", "lethargy", "limp", "lymphadenopathy",
+ "pallor", "proteinuria", "pruritus", "rash", "sore throat", "splenomegaly",
+ "stridor", "syncope", "vomiting", "weight loss", "wheeze", "wheezing",
+}
+
+# Plural forms that are the same concept as their singular.
+IRREGULAR_SINGULARS = {"ies": "y", "ses": "sis", "s": ""}
+
+
+def normalise(name: str) -> str:
+ """Tidy a tag for display: single spaces, sane acronym casing."""
+ clean = re.sub(r"\s+", " ", (name or "").strip()).strip(" ,;.-")
+ if not clean:
+ return ""
+ words = []
+ for word in clean.split(" "):
+ # Genetics notation like "46,Xx" needs each comma-part fixed.
+ if "," in word:
+ words.append(",".join(ACRONYMS.get(part, part) for part in word.split(",")))
+ else:
+ words.append(ACRONYMS.get(word, word))
+ return " ".join(words)
+
+
+def canonical_key(name: str) -> str:
+ """Key under which near-duplicates collapse: lowercase, unpunctuated, singular."""
+ key = re.sub(r"[^a-z0-9 ]+", "", (name or "").lower()).strip()
+ key = re.sub(r"\s+", " ", key)
+ if not key:
+ return ""
+ head, _, last = key.rpartition(" ")
+ for suffix, replacement in IRREGULAR_SINGULARS.items():
+ if last.endswith(suffix) and len(last) > len(suffix) + 2:
+ last = last[: -len(suffix)] + replacement
+ break
+ return f"{head} {last}".strip()
+
+
+def looks_like_symptom(name: str) -> bool:
+ """True only when the tag *is* a presenting feature, not merely contains one."""
+ return canonical_key(name) in {canonical_key(term) for term in SYMPTOM_NAMES}
+
+
+def main():
+ apply_changes = "--apply" in sys.argv
+ db = SessionLocal()
+ try:
+ rows = db.execute(sa_text(
+ "SELECT id, name, type FROM question_tags ORDER BY id")).fetchall()
+ uses = dict(db.execute(sa_text(
+ "SELECT tag_id, COUNT(*) FROM question_tag_links GROUP BY tag_id")).fetchall())
+
+ renamed, merged, reclassified, deleted = [], [], [], []
+ # Within a type, everything sharing a canonical key becomes one tag.
+ groups: dict[tuple[str, str], list] = defaultdict(list)
+ for tag_id, name, tag_type in rows:
+ key = canonical_key(name)
+ if not key:
+ deleted.append((tag_id, name, "blank name"))
+ continue
+ groups[(tag_type, key)].append((tag_id, name))
+
+ for (tag_type, _key), members in groups.items():
+ # Keep the most-used tag; on a tie prefer the lowest id for stability.
+ members.sort(key=lambda m: (-uses.get(m[0], 0), m[0]))
+ keep_id, keep_name = members[0]
+ tidy = normalise(keep_name)
+ if tidy and tidy != keep_name:
+ renamed.append((keep_id, keep_name, tidy))
+ if apply_changes:
+ db.execute(sa_text("UPDATE question_tags SET name = :n WHERE id = :i"),
+ {"n": tidy, "i": keep_id})
+
+ for dup_id, dup_name in members[1:]:
+ merged.append((dup_id, dup_name, keep_id, tidy or keep_name))
+ if apply_changes:
+ # Move links, skipping any that would duplicate an existing pair.
+ db.execute(sa_text("""
+ UPDATE question_tag_links SET tag_id = :keep
+ WHERE tag_id = :dup AND question_id NOT IN (
+ SELECT question_id FROM question_tag_links WHERE tag_id = :keep)
+ """), {"keep": keep_id, "dup": dup_id})
+ db.execute(sa_text("""
+ UPDATE flashcard_tag_links SET tag_id = :keep
+ WHERE tag_id = :dup AND flashcard_id NOT IN (
+ SELECT flashcard_id FROM flashcard_tag_links WHERE tag_id = :keep)
+ """), {"keep": keep_id, "dup": dup_id})
+ db.execute(sa_text("DELETE FROM question_tag_links WHERE tag_id = :dup"), {"dup": dup_id})
+ db.execute(sa_text("DELETE FROM flashcard_tag_links WHERE tag_id = :dup"), {"dup": dup_id})
+ db.execute(sa_text("DELETE FROM question_tags WHERE id = :dup"), {"dup": dup_id})
+
+ # A presenting feature filed as a disease belongs with the keywords.
+ if tag_type == "disease" and looks_like_symptom(tidy or keep_name):
+ reclassified.append((keep_id, tidy or keep_name))
+ if apply_changes:
+ # Only if the keyword vocabulary does not already hold it.
+ clash = db.execute(sa_text(
+ "SELECT id FROM question_tags WHERE type='keyword' AND lower(name)=lower(:n)"),
+ {"n": tidy or keep_name}).first()
+ if clash:
+ db.execute(sa_text("""
+ UPDATE question_tag_links SET tag_id = :keep
+ WHERE tag_id = :dup AND question_id NOT IN (
+ SELECT question_id FROM question_tag_links WHERE tag_id = :keep)
+ """), {"keep": clash[0], "dup": keep_id})
+ db.execute(sa_text("DELETE FROM question_tag_links WHERE tag_id = :dup"), {"dup": keep_id})
+ db.execute(sa_text("DELETE FROM question_tags WHERE id = :dup"), {"dup": keep_id})
+ else:
+ db.execute(sa_text("UPDATE question_tags SET type='keyword' WHERE id = :i"),
+ {"i": keep_id})
+
+ # Anything nothing references is dead weight in every picker.
+ orphans = db.execute(sa_text("""
+ SELECT id, name, type FROM question_tags t
+ WHERE NOT EXISTS (SELECT 1 FROM question_tag_links l WHERE l.tag_id = t.id)
+ AND NOT EXISTS (SELECT 1 FROM flashcard_tag_links f WHERE f.tag_id = t.id)
+ """)).fetchall()
+ for tag_id, name, tag_type in orphans:
+ deleted.append((tag_id, name, f"unused {tag_type}"))
+ if apply_changes:
+ db.execute(sa_text("DELETE FROM question_tags WHERE id = :i"), {"i": tag_id})
+
+ if apply_changes:
+ db.commit()
+
+ print(f"{'APPLIED' if apply_changes else 'DRY RUN'}")
+ print(f" tags before : {len(rows)}")
+ print(f" renamed : {len(renamed)}")
+ print(f" merged away : {len(merged)}")
+ print(f" disease→keyword : {len(reclassified)}")
+ print(f" deleted (unused) : {len(deleted)}")
+ print(f" tags after : {len(rows) - len(merged) - len(deleted)}")
+ for old_id, old, new in renamed[:10]:
+ print(f" rename {old!r} -> {new!r}")
+ for _dup_id, dup, _keep_id, keep in merged[:10]:
+ print(f" merge {dup!r} -> {keep!r}")
+ for _tid, name in reclassified[:10]:
+ print(f" symptom {name!r} disease -> keyword")
+ if not apply_changes:
+ print("\n Re-run with --apply to write these changes.")
+ finally:
+ db.close()
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 922aea8..739bcd3 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -18,6 +18,7 @@ const AccountPage = lazy(() => import('./pages/AccountPage'))
const SettingsPage = lazy(() => import('./pages/SettingsPage'))
const QuestionBankPage = lazy(() => import('./pages/QuestionBankPage'))
const QuestionManagerPage = lazy(() => import('./pages/QuestionManagerPage'))
+const CategoriesPage = lazy(() => import('./pages/CategoriesPage'))
const AnalysisPage = lazy(() => import('./pages/AnalysisPage'))
const JobsPage = lazy(() => import('./pages/JobsPage'))
const TrashPage = lazy(() => import('./pages/TrashPage'))
@@ -114,6 +115,7 @@ function AppRoutes() {
} />
} />
} />
+ } />
diff --git a/frontend/src/pages/CategoriesPage.css b/frontend/src/pages/CategoriesPage.css
new file mode 100644
index 0000000..3ede05b
--- /dev/null
+++ b/frontend/src/pages/CategoriesPage.css
@@ -0,0 +1,48 @@
+/* Category management — the tree, with rename, reparent and delete-with-move.
+ Mobile-first: rows stack and actions wrap under the name. */
+
+.cat-page { max-width: 900px; margin: 0 auto; padding-bottom: 40px; }
+.cat-header { display: flex; justify-content: space-between; align-items: flex-end; gap: 12px; flex-wrap: wrap; margin-bottom: 14px; }
+.cat-header h1 { margin: 0 0 4px; font-size: 1.35rem; }
+.cat-header p { margin: 0; color: var(--text-muted); font-size: 0.87rem; }
+
+.cat-toolbar { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-bottom: 12px; }
+.cat-toolbar input, .cat-toolbar select {
+ padding: 8px 12px; border: 1px solid var(--border); border-radius: 8px;
+ background: var(--input-bg); color: var(--text); font-size: 0.88rem;
+}
+.cat-search { flex: 1; min-width: 180px; }
+.cat-count { margin-left: auto; font-size: 0.8rem; color: var(--text-muted); }
+
+.cat-tree { list-style: none; margin: 0; padding: 0; background: var(--card-bg); border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
+.cat-tree .cat-tree { border: 0; border-radius: 0; background: none; }
+.cat-row {
+ display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
+ padding: 11px 14px; border-bottom: 1px solid var(--border);
+}
+.cat-row:hover { background: var(--bg); }
+.cat-name { font-size: 0.92rem; font-weight: 600; overflow-wrap: anywhere; }
+.cat-badge {
+ font-size: 0.7rem; font-weight: 600; border-radius: 20px; padding: 2px 9px;
+ background: var(--bg); color: var(--text-muted); border: 1px solid var(--border);
+}
+.cat-actions { margin-left: auto; display: flex; gap: 6px; flex-wrap: wrap; }
+.cat-child { padding-left: 22px; border-left: 2px solid var(--border); margin-left: 14px; }
+
+.cat-edit { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; width: 100%; padding: 4px 0; }
+.cat-edit input, .cat-edit select {
+ padding: 7px 11px; border: 1px solid var(--border); border-radius: 8px;
+ background: var(--input-bg); color: var(--text); font-size: 0.87rem;
+}
+.cat-edit input { flex: 1; min-width: 150px; }
+.cat-confirm { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; width: 100%; font-size: 0.84rem; color: var(--wrong-fg); padding: 4px 0; }
+.cat-error { color: var(--wrong-fg); font-size: 0.84rem; margin: 10px 0 0; }
+.cat-empty { background: var(--card-bg); border: 1px solid var(--border); border-radius: 10px; padding: 28px; text-align: center; color: var(--text-muted); }
+
+@media (max-width: 640px) {
+ .cat-row { align-items: flex-start; }
+ .cat-actions { margin-left: 0; width: 100%; }
+ .cat-actions .btn { flex: 1; }
+ .cat-child { padding-left: 12px; margin-left: 8px; }
+ .cat-count { margin-left: 0; }
+}
diff --git a/frontend/src/pages/CategoriesPage.jsx b/frontend/src/pages/CategoriesPage.jsx
new file mode 100644
index 0000000..3beccc7
--- /dev/null
+++ b/frontend/src/pages/CategoriesPage.jsx
@@ -0,0 +1,222 @@
+import { useState, useEffect, useCallback, useMemo } from 'react'
+import { Link } from 'react-router-dom'
+import api from '../api/client'
+import './CategoriesPage.css'
+
+const apiError = (err, fallback) => {
+ const detail = err?.response?.data?.detail
+ if (typeof detail === 'string') return detail
+ if (Array.isArray(detail)) return detail.map(d => d?.msg).filter(Boolean).join('; ') || fallback
+ return fallback
+}
+
+export default function CategoriesPage() {
+ const [categories, setCategories] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState('')
+ const [query, setQuery] = useState('')
+
+ const [editing, setEditing] = useState(null) // category id being renamed/reparented
+ const [draftName, setDraftName] = useState('')
+ const [draftParent, setDraftParent] = useState('')
+ const [deleting, setDeleting] = useState(null) // category id awaiting confirmation
+ const [moveTo, setMoveTo] = useState('')
+ const [creating, setCreating] = useState(false)
+ const [newName, setNewName] = useState('')
+ const [newParent, setNewParent] = useState('')
+ const [busy, setBusy] = useState(false)
+
+ const load = useCallback(() => {
+ setLoading(true)
+ api.get('/question-categories/')
+ .then(res => setCategories(Array.isArray(res.data) ? res.data : []))
+ .catch(err => setError(apiError(err, 'Could not load categories')))
+ .finally(() => setLoading(false))
+ }, [])
+
+ useEffect(() => { load() }, [load])
+
+ const childrenOf = useMemo(() => {
+ const map = {}
+ for (const cat of categories) (map[cat.parent_id || 0] ||= []).push(cat)
+ for (const list of Object.values(map)) list.sort((a, b) => a.name.localeCompare(b.name))
+ return map
+ }, [categories])
+
+ const matches = useCallback((cat) => {
+ if (!query.trim()) return true
+ const needle = query.trim().toLowerCase()
+ if (cat.name.toLowerCase().includes(needle)) return true
+ return (childrenOf[cat.id] || []).some(matches)
+ }, [query, childrenOf])
+
+ // A category cannot become its own descendant's child.
+ const descendantIds = useCallback((id) => {
+ const out = new Set()
+ const walk = (parent) => {
+ for (const child of childrenOf[parent] || []) { out.add(child.id); walk(child.id) }
+ }
+ walk(id)
+ return out
+ }, [childrenOf])
+
+ const startEdit = (cat) => {
+ setDeleting(null)
+ setEditing(cat.id)
+ setDraftName(cat.name)
+ setDraftParent(cat.parent_id ?? '')
+ }
+
+ const save = async (cat) => {
+ const name = draftName.trim()
+ if (!name) { setError('A category needs a name'); return }
+ setBusy(true); setError('')
+ try {
+ await api.patch(`/question-categories/${cat.id}`, {
+ name, description: cat.description ?? null,
+ parent_id: draftParent === '' ? null : Number(draftParent),
+ })
+ setEditing(null)
+ load()
+ } catch (err) { setError(apiError(err, 'Could not save this category')) }
+ finally { setBusy(false) }
+ }
+
+ const remove = async (cat) => {
+ setBusy(true); setError('')
+ try {
+ await api.delete(`/question-categories/${cat.id}`,
+ { params: moveTo === '' ? {} : { move_to: Number(moveTo) } })
+ setDeleting(null); setMoveTo('')
+ load()
+ } catch (err) { setError(apiError(err, 'Could not delete this category')) }
+ finally { setBusy(false) }
+ }
+
+ const create = async () => {
+ const name = newName.trim()
+ if (!name) { setError('A category needs a name'); return }
+ setBusy(true); setError('')
+ try {
+ await api.post('/question-categories/', {
+ name, description: null, parent_id: newParent === '' ? null : Number(newParent),
+ })
+ setCreating(false); setNewName(''); setNewParent('')
+ load()
+ } catch (err) { setError(apiError(err, 'Could not create this category')) }
+ finally { setBusy(false) }
+ }
+
+ const parentOptions = (excludeId) => {
+ const blocked = excludeId ? new Set([excludeId, ...descendantIds(excludeId)]) : new Set()
+ return categories.filter(c => !blocked.has(c.id))
+ }
+
+ const renderTree = (parentId, depth = 0) => {
+ const branch = (childrenOf[parentId] || []).filter(matches)
+ if (!branch.length) return null
+ return (
+
0 ? ' cat-child' : ''}`}>
+ {branch.map(cat => (
+ -
+
+ {editing === cat.id ? (
+
+ setDraftName(e.target.value)}
+ onKeyDown={e => { if (e.key === 'Enter') save(cat); if (e.key === 'Escape') setEditing(null) }} />
+
+
+
+
+ ) : (
+ <>
+
{cat.name}
+
{cat.question_count} question{cat.question_count === 1 ? '' : 's'}
+ {(childrenOf[cat.id] || []).length > 0 && (
+
{childrenOf[cat.id].length} sub
+ )}
+
+
+
+
+ >
+ )}
+
+ {deleting === cat.id && (
+
+ {(childrenOf[cat.id] || []).length > 0
+ ? Move its {childrenOf[cat.id].length} subcategor{childrenOf[cat.id].length === 1 ? 'y' : 'ies'} first.
+ : <>
+ Delete “{cat.name}”? Move its {cat.question_count} question{cat.question_count === 1 ? '' : 's'} to:
+
+
+ >}
+
+
+ )}
+
+ {renderTree(cat.id, depth + 1)}
+
+ ))}
+
+ )
+ }
+
+ return (
+
+
+
+
Categories
+
Organise the system tree: rename, move under a different parent, or delete and rehome its questions.
+
+
+ Question bank
+
+
+
+
+ {creating && (
+
+ setNewName(e.target.value)}
+ onKeyDown={e => { if (e.key === 'Enter') create() }} />
+
+
+
+
+ )}
+
+
+ setQuery(e.target.value)}
+ placeholder="Search categories…" aria-label="Search categories" />
+ {categories.length} categories
+
+
+ {error &&
{error}
}
+
+ {loading ? (
+
+ ) : categories.length === 0 ? (
+
No categories yet.
+ ) : (
+ renderTree(0) ||
No categories match “{query}”.
+ )}
+
+ )
+}
diff --git a/frontend/src/pages/CategoriesPage.test.jsx b/frontend/src/pages/CategoriesPage.test.jsx
new file mode 100644
index 0000000..69dde28
--- /dev/null
+++ b/frontend/src/pages/CategoriesPage.test.jsx
@@ -0,0 +1,112 @@
+import { render, screen, waitFor, within } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { MemoryRouter } from 'react-router-dom'
+import { expect, it, vi, beforeEach } from 'vitest'
+import CategoriesPage from './CategoriesPage'
+import api from '../api/client'
+
+vi.mock('../api/client', () => ({ default: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), delete: vi.fn() } }))
+
+const CATS = [
+ { id: 1, name: 'Root', parent_id: null, question_count: 2, description: null },
+ { id: 2, name: 'Child', parent_id: 1, question_count: 5, description: null },
+ { id: 3, name: 'Other', parent_id: null, question_count: 0, description: null },
+]
+
+const mockCats = (cats = CATS) => api.get.mockResolvedValue({ data: cats })
+
+beforeEach(() => { vi.clearAllMocks(); mockCats() })
+
+const mount = () => render()
+
+it('renders the tree with question and subcategory counts', async () => {
+ mount()
+ expect(await screen.findByText('Root')).toBeInTheDocument()
+ expect(screen.getByText('Child')).toBeInTheDocument()
+ expect(screen.getByText('5 questions')).toBeInTheDocument()
+ expect(screen.getByText('1 sub')).toBeInTheDocument()
+})
+
+it('reparents a category and reloads', async () => {
+ mount()
+ await screen.findByText('Child')
+ api.patch.mockResolvedValue({ data: {} })
+
+ await userEvent.click(screen.getByRole('button', { name: 'Edit category Child' }))
+ await userEvent.selectOptions(screen.getByLabelText('Parent for Child'), '3')
+ await userEvent.click(screen.getByRole('button', { name: 'Save category' }))
+
+ await waitFor(() => expect(api.patch).toHaveBeenCalledWith('/question-categories/2',
+ { name: 'Child', description: null, parent_id: 3 }))
+})
+
+it('never offers a category its own descendant as a parent', async () => {
+ mount()
+ await screen.findByText('Root')
+ await userEvent.click(screen.getByRole('button', { name: 'Edit category Root' }))
+ const parent = screen.getByLabelText('Parent for Root')
+ // Root cannot sit under Child (its own descendant) or under itself.
+ expect([...parent.options].map(o => o.text)).toEqual(['No parent (top level)', 'Other'])
+})
+
+it('deletes a leaf category, rehoming its questions', async () => {
+ mount()
+ await screen.findByText('Child')
+ api.delete.mockResolvedValue({})
+
+ await userEvent.click(screen.getByRole('button', { name: 'Delete category Child' }))
+ await userEvent.selectOptions(screen.getByLabelText('Move questions from Child to'), '3')
+ await userEvent.click(screen.getByRole('button', { name: 'Delete category' }))
+
+ await waitFor(() => expect(api.delete).toHaveBeenCalledWith('/question-categories/2',
+ { params: { move_to: 3 } }))
+})
+
+it('defaults to uncategorized when no destination is picked', async () => {
+ mount()
+ await screen.findByText('Child')
+ api.delete.mockResolvedValue({})
+ await userEvent.click(screen.getByRole('button', { name: 'Delete category Child' }))
+ await userEvent.click(screen.getByRole('button', { name: 'Delete category' }))
+ await waitFor(() => expect(api.delete).toHaveBeenCalledWith('/question-categories/2', { params: {} }))
+})
+
+it('refuses to delete a category that still has subcategories', async () => {
+ mount()
+ await screen.findByText('Root')
+ await userEvent.click(screen.getByRole('button', { name: 'Delete category Root' }))
+ expect(screen.getByRole('alert')).toHaveTextContent('Move its 1 subcategory first')
+ expect(screen.queryByRole('button', { name: 'Delete category' })).not.toBeInTheDocument()
+})
+
+it('creates a category under a chosen parent', async () => {
+ mount()
+ await screen.findByText('Root')
+ api.post.mockResolvedValue({ data: { id: 9 } })
+
+ await userEvent.click(screen.getByRole('button', { name: '+ New category' }))
+ await userEvent.type(screen.getByLabelText('New category name'), 'Neonatology')
+ await userEvent.selectOptions(screen.getByLabelText('New category parent'), '1')
+ await userEvent.click(screen.getByRole('button', { name: 'Create' }))
+
+ await waitFor(() => expect(api.post).toHaveBeenCalledWith('/question-categories/',
+ { name: 'Neonatology', description: null, parent_id: 1 }))
+})
+
+it('filters the tree by search, keeping parents of matches', async () => {
+ mount()
+ await screen.findByText('Root')
+ await userEvent.type(screen.getByLabelText('Search categories'), 'child')
+ expect(screen.getByText('Child')).toBeInTheDocument()
+ expect(screen.getByText('Root')).toBeInTheDocument() // kept: it holds the match
+ expect(screen.queryByText('Other')).not.toBeInTheDocument()
+})
+
+it('surfaces a server refusal', async () => {
+ mount()
+ await screen.findByText('Child')
+ api.patch.mockRejectedValue({ response: { data: { detail: 'A category cannot be its own ancestor' } } })
+ await userEvent.click(screen.getByRole('button', { name: 'Edit category Child' }))
+ await userEvent.click(screen.getByRole('button', { name: 'Save category' }))
+ expect(await screen.findByRole('alert')).toHaveTextContent('cannot be its own ancestor')
+})
diff --git a/frontend/src/pages/CustomQuizPage.css b/frontend/src/pages/CustomQuizPage.css
index 18fe683..c9b50b3 100644
--- a/frontend/src/pages/CustomQuizPage.css
+++ b/frontend/src/pages/CustomQuizPage.css
@@ -129,16 +129,19 @@
.custom-test-note { color: var(--text-muted); font-size: 0.8rem; line-height: 1.6; margin: 8px 0 0; }
.custom-test-error { color: var(--wrong-fg); font-size: 0.84rem; margin: 8px 0 0; }
-/* ── Sticky bottom bar: mode and Start stay reachable without scrolling ── */
+/* ── Sticky bottom bar: mode and Start stay reachable without scrolling ──
+ Full-bleed via the viewport, not by guessing the page's own padding: a
+ hardcoded negative margin overflowed whenever the container padding differed. */
.custom-test-bar {
grid-column: 1 / -1;
position: sticky; bottom: 0; z-index: 40;
- margin: 4px -16px -100px; padding: 0 16px;
+ margin-top: 4px;
+ margin-inline: calc(50% - 50vw);
+ padding-inline: max(16px, calc(50vw - 560px));
background: var(--card-bg); border-top: 1px solid var(--border);
box-shadow: 0 -6px 20px rgba(0, 0, 0, 0.06);
}
.custom-test-bar-inner {
- max-width: 1120px; margin: 0 auto;
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
padding: 12px 0 calc(12px + env(safe-area-inset-bottom));
}
@@ -245,16 +248,27 @@
.custom-test-grid { grid-template-columns: 1fr; }
}
@media (max-width: 640px) {
- .custom-test { padding-bottom: 120px; }
+ .custom-test { padding-bottom: 132px; padding-inline: 2px; }
.custom-test-top { grid-template-columns: 1fr; }
.custom-test-reset { justify-self: start; padding-left: 0; }
+ .custom-test-card > h2 { padding: 13px 14px; font-size: 1rem; }
+ .custom-test-card-pad, .custom-test-adaptive, .custom-test-countbox { padding: 12px 14px; }
+ .facet-row { padding: 13px 14px; gap: 10px; }
+
.facet-overlay { align-items: flex-end; }
.facet-panel { width: 100%; height: 88vh; border-radius: 16px 16px 0 0; }
- .facet-chip { max-width: 40vw; }
+ .facet-panel-body label { padding: 12px 2px; } /* comfortable tap targets */
+
+ /* Long names must truncate, never widen the row into a sideways scroll. */
+ .facet-row-summary { min-width: 0; flex: 1; justify-content: flex-end; }
+ .facet-chip, .facet-row-value { min-width: 0; max-width: none; }
+
+ /* Two tidy rows: modes across the top, then Refresh and Create together. */
.custom-test-bar-label { display: none; }
- .custom-test-bar-inner { padding: 10px 0 calc(10px + env(safe-area-inset-bottom)); }
- .custom-test-modes { flex: 1; }
+ .custom-test-bar-inner { padding: 10px 0 calc(10px + env(safe-area-inset-bottom)); gap: 8px; }
+ .custom-test-modes { flex: 1 0 100%; }
.custom-test-modes label { flex: 1; }
- .custom-test-modes span { text-align: center; padding: 9px 10px; }
- .custom-test-start { width: 100%; margin-left: 0; }
+ .custom-test-modes span { text-align: center; padding: 10px; }
+ .custom-test-bar-inner > .btn-secondary { flex: 0 0 auto; }
+ .custom-test-start { flex: 1; width: auto; margin-left: 0; min-width: 0; }
}
diff --git a/frontend/src/pages/QuestionBankPage.css b/frontend/src/pages/QuestionBankPage.css
index fa86488..e7664b8 100644
--- a/frontend/src/pages/QuestionBankPage.css
+++ b/frontend/src/pages/QuestionBankPage.css
@@ -47,3 +47,13 @@ details[open] > .category-tree-branch .category-tree-chevron { transform: rotate
.bank-filters-overlay { align-items: flex-end; }
.bank-filters-panel { width: 100%; height: 88vh; border-radius: 16px 16px 0 0; }
}
+
+/* ── Filter facets ────────────────────────────────────────────────── */
+.bank-filters-bar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 10px; }
+.bank-filters-summary { margin-left: auto; font-size: .8rem; color: var(--text-muted); }
+.bank-facets { margin-bottom: 14px; border: 1px solid var(--border); border-radius: 10px; background: var(--card-bg); }
+.bank-facets .facet-row:first-child { border-top: 0; }
+
+@media (max-width: 640px) {
+ .bank-filters-summary { margin-left: 0; width: 100%; }
+}
diff --git a/frontend/src/pages/QuestionBankPage.jsx b/frontend/src/pages/QuestionBankPage.jsx
index cd456c2..e0a2567 100644
--- a/frontend/src/pages/QuestionBankPage.jsx
+++ b/frontend/src/pages/QuestionBankPage.jsx
@@ -4,6 +4,8 @@ import { useAuth } from '../context/AuthContext'
import api from '../api/client'
import Dialog from '../components/Dialog'
import CategoryTree from '../components/CategoryTree'
+import FacetPicker, { FacetRow } from '../components/FacetPicker'
+import './CustomQuizPage.css' // facet row + picker panel styles
import { useDialog } from '../hooks/useDialog'
const TeachChat = lazy(() => import('../components/TeachChat'))
@@ -11,6 +13,14 @@ const RichEditor = lazy(() => import('../components/RichEditor'))
import QuestionReadingLinks from '../components/QuestionReadingLinks'
import { QuestionEditModal, CreateQuestionModal } from '../components/QuestionEditors'
+const DIFFICULTY_LABEL = { '': 'Any', easy: 'Easy', medium: 'Medium', hard: 'Hard' }
+
+/** "All", one name, or the first name plus a +N badge. */
+const summarise = (names) => ({
+ summary: names.length === 0 ? 'All' : names[0],
+ extra: Math.max(0, names.length - 1),
+})
+
function apiError(err, fallback) {
const detail = err?.response?.data?.detail
if (typeof detail === 'string') return detail
@@ -299,6 +309,7 @@ export default function QuestionBankPage() {
const [pageSize, setPageSize] = useState(50)
const [tags, setTags] = useState({ subjects: [], diseases: [], keywords: [] })
const [selectedTagIds, setSelectedTagIds] = useState([])
+ const [openFacet, setOpenFacet] = useState(null)
const [showTags, setShowTags] = useState(false)
const [showCreateQuestion, setShowCreateQuestion] = useState(false)
const [showImport, setShowImport] = useState(false)
@@ -347,6 +358,58 @@ export default function QuestionBankPage() {
return () => clearTimeout(debounceRef.current)
}, [searchQuery, catIdsKey, showUncategorized, showFavorites, showMyQuestions, pageSize, tagIdsKey, difficulty, bankArticleIds.join(',')])
+ const subjectTags = tags.subjects || []
+ const diseaseTags = tags.diseases || []
+ const keywordTags = tags.keywords || []
+ const nameOf = (list, id) => list.find(t => t.id === id)?.name
+ const selectedIn = (list) => selectedTagIds.map(id => nameOf(list, id)).filter(Boolean)
+
+ const systemsFacet = summarise(filterCatIds.map(id => categories.find(c => c.id === id)?.name).filter(Boolean))
+ const disciplinesFacet = summarise(selectedIn(subjectTags))
+ const diseasesFacet = summarise(selectedIn(diseaseTags))
+ const symptomsFacet = summarise(selectedIn(keywordTags))
+ const articlesFacet = summarise(bankArticleIds.map(id => articles.find(a => a.id === id)?.title).filter(Boolean))
+ const statusFacet = showFavorites ? { summary: 'Saved', active: true }
+ : showMyQuestions ? { summary: 'My questions', active: true }
+ : showUncategorized ? { summary: 'Uncategorized', active: true }
+ : { summary: 'All', active: false }
+
+ const activeFilterCount = filterCatIds.length + selectedTagIds.length + bankArticleIds.length
+ + (difficulty ? 1 : 0) + (statusFacet.active ? 1 : 0)
+
+ const resetFilters = () => {
+ setFilterCatIds([]); setSelectedTagIds([]); setBankArticleIds([]); setDifficulty('')
+ setShowFavorites(false); setShowMyQuestions(false); setShowUncategorized(false)
+ }
+
+ const setStatus = (value) => {
+ setShowFavorites(value === 'favorites')
+ setShowMyQuestions(value === 'mine')
+ setShowUncategorized(value === 'uncategorized')
+ }
+
+ /** Checklist for a tag vocabulary: most-used first, everything reachable by search. */
+ const tagChecklist = (list, query) => {
+ const shown = query
+ ? list.filter(t => t.name.toLowerCase().includes(query))
+ : list.slice(0, 60) // the tail is long; search reaches it
+ if (!shown.length) return Nothing matches that search.
+ return (
+ <>
+ {shown.map(tag => (
+
+ ))}
+ {!query && list.length > shown.length && (
+ {list.length - shown.length} more — search to narrow.
+ )}
+ >
+ )
+ }
+
const toggleTag = (tagId) => {
setSelectedTagIds(prev => {
const n = prev.includes(tagId) ? prev.filter(id => id !== tagId) : [...prev, tagId]
@@ -643,118 +706,30 @@ export default function QuestionBankPage() {
)}
- {/* Filter side box (AMBOSS-style) */}
+ {/* Filters — one row per facet, each opening a search + checklist panel. */}
-
+
{filtersOpen && (
-
e.target === e.currentTarget && setFiltersOpen(false)}>
-
-
- Filters
- setFiltersOpen(false)}>✕
-
-
-
-
-
+
+ setOpenFacet('status')} />
+ setOpenFacet('difficulty')} />
+ setOpenFacet('systems')} />
+ setOpenFacet('disciplines')} />
+ setOpenFacet('diseases')} />
+ setOpenFacet('symptoms')} />
+ setOpenFacet('articles')} />
)}
@@ -901,6 +876,83 @@ export default function QuestionBankPage() {
)}
+
+ setOpenFacet(null)}
+ onReset={() => setStatus('all')} helper="Narrow to a slice of the bank.">
+ {() => [['all', `All (${total})`], ['favorites', `Saved (${favorites.length})`],
+ ['mine', 'My questions'], ['uncategorized', 'Uncategorized']].map(([value, label]) => (
+
+ ))}
+
+
+ setOpenFacet(null)}
+ onReset={() => setDifficulty('')} helper="Applies to every question listed.">
+ {() => ['', 'easy', 'medium', 'hard'].map(value => (
+
+ ))}
+
+
+ setOpenFacet(null)}
+ onReset={() => setFilterCatIds([])}
+ helper="By default all systems are included unless filters are selected.">
+ {query => {
+ const shown = categories.filter(c => !query || c.name.toLowerCase().includes(query))
+ if (!shown.length) return Nothing matches that search.
+ return shown.map(cat => (
+
+ ))
+ }}
+
+
+ setOpenFacet(null)}
+ onReset={() => setSelectedTagIds(ids => ids.filter(id => !subjectTags.some(t => t.id === id)))}
+ helper="Specialty areas, most used first.">
+ {query => tagChecklist(subjectTags, query)}
+
+
+ setOpenFacet(null)}
+ onReset={() => setSelectedTagIds(ids => ids.filter(id => !diseaseTags.some(t => t.id === id)))}
+ helper="Named conditions, most used first.">
+ {query => tagChecklist(diseaseTags, query)}
+
+
+ setOpenFacet(null)}
+ onReset={() => setSelectedTagIds(ids => ids.filter(id => !keywordTags.some(t => t.id === id)))}
+ helper="Presenting features and keywords, most used first.">
+ {query => tagChecklist(keywordTags, query)}
+
+
+ setOpenFacet(null)}
+ onReset={() => setBankArticleIds([])} helper="Questions linked to a topic article.">
+ {query => {
+ const shown = articles.filter(a => !query || (a.title || '').toLowerCase().includes(query))
+ if (!shown.length) return No articles match.
+ return shown.map(article => (
+
+ ))
+ }}
+
)
}
diff --git a/frontend/src/pages/QuestionBankPage.test.jsx b/frontend/src/pages/QuestionBankPage.test.jsx
index f8c468d..2174b40 100644
--- a/frontend/src/pages/QuestionBankPage.test.jsx
+++ b/frontend/src/pages/QuestionBankPage.test.jsx
@@ -136,73 +136,6 @@ describe('QuestionBankPage review regressions', () => {
expect(await screen.findByText('Quiz title is invalid')).toBeInTheDocument()
})
- it('reloads active parent results after moving its child', async () => {
- let moved = false
- const cats = [
- { id: 1, name: 'Root', question_count: 1, breadcrumbs: [{ id: 1, name: 'Root' }] },
- { id: 2, name: 'Child', parent_id: 1, question_count: 1, breadcrumbs: [{ id: 1, name: 'Root' }, { id: 2, name: 'Child' }] },
- { id: 3, name: 'Other', question_count: 0, breadcrumbs: [{ id: 3, name: 'Other' }] },
- ]
- const initial = api.get.getMockImplementation()
- api.get.mockImplementation((url, options) => {
- if (url === '/question-categories/') return Promise.resolve({ data: cats })
- if (url === '/questions/bank') {
- const questions = moved && options?.params?.category_ids === '1' ? [] : [{ id: 10, question_text: 'Child question', options: ['Yes', 'No'], question_type: 'mcq', correct_answer: 'Yes' }]
- return Promise.resolve({ data: { questions, total: questions.length } })
- }
- return initial(url)
- })
- api.patch.mockImplementation(() => { moved = true; return Promise.resolve({ data: {} }) })
- renderPage()
- await openFilters()
- await userEvent.click(await screen.findByRole('button', { name: 'Root (1)', exact: true }))
- await screen.findByText('Child question')
- await waitFor(() => expect(api.get).toHaveBeenCalledWith('/questions/bank', expect.objectContaining({ params: expect.objectContaining({ category_ids: '1' }) })))
- await userEvent.click(screen.getByRole('button', { name: 'Edit category Child' }))
- await userEvent.selectOptions(screen.getByLabelText('Parent category'), '3')
- await userEvent.click(screen.getByRole('button', { name: 'Save category' }))
- await waitFor(() => expect(screen.queryByText('Child question')).not.toBeInTheDocument())
- expect(await screen.findByText('0 questions total')).toBeInTheDocument()
- })
-
- it('offers relocation even when visible count is zero', async () => {
- const initial = api.get.getMockImplementation()
- api.get.mockImplementation(url => url === '/question-categories/' ? Promise.resolve({ data: [
- { id: 1, name: 'Hidden assignments', question_count: 0 }, { id: 3, name: 'Destination', question_count: 0 },
- ] }) : initial(url))
- api.delete.mockResolvedValue({})
- renderPage()
- await openFilters()
- await userEvent.click(await screen.findByRole('button', { name: 'Delete category Hidden assignments' }))
- expect(screen.getByText(/including private and course questions excluded/)).toBeInTheDocument()
- await userEvent.selectOptions(screen.getByLabelText('Move all assigned questions to:'), '3')
- await userEvent.click(screen.getByRole('button', { name: 'Delete category', exact: true }))
- await waitFor(() => expect(api.delete).toHaveBeenCalledWith('/question-categories/1', { params: { move_to: 3 } }))
- })
-})
-
-describe('QuestionBankPage category hierarchy', () => {
- it('edits parent assignment with breadcrumb choices and excludes descendants', async () => {
- vi.clearAllMocks()
- const cats = [
- { id: 1, name: 'Root', parent_id: null, question_count: 2, breadcrumbs: [{ id: 1, name: 'Root' }] },
- { id: 2, name: 'Child', parent_id: 1, question_count: 2, breadcrumbs: [{ id: 1, name: 'Root' }, { id: 2, name: 'Child' }] },
- { id: 3, name: 'Other', parent_id: null, question_count: 0, breadcrumbs: [{ id: 3, name: 'Other' }] },
- ]
- mockInitialRequests()
- const initialGet = api.get.getMockImplementation()
- api.get.mockImplementation(url => url === '/question-categories/' ? Promise.resolve({ data: cats }) : initialGet(url))
- api.patch = vi.fn().mockResolvedValue({ data: {} })
- renderPage()
- expect(screen.queryByRole('link', { name: 'Create Custom Test' })).not.toBeInTheDocument()
- await openFilters()
- 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'])
- await userEvent.selectOptions(parent, '3')
- await userEvent.click(screen.getByRole('button', { name: 'Save category' }))
- await waitFor(() => expect(api.patch).toHaveBeenCalledWith('/question-categories/1', { name: 'Root', parent_id: 3, description: null }))
- })
})
describe('QuestionBankPage edit modal multi-category', () => {
diff --git a/frontend/src/pages/QuestionManagerPage.jsx b/frontend/src/pages/QuestionManagerPage.jsx
index a2338f3..b87e3ce 100644
--- a/frontend/src/pages/QuestionManagerPage.jsx
+++ b/frontend/src/pages/QuestionManagerPage.jsx
@@ -149,6 +149,7 @@ export default function QuestionManagerPage() {
+ Categories
Open question bank
setCreating(true)}>+ New question