feat: one session's topics, asked the same three ways — and a way back

The session analysis ranked its weakest topics by primary category only,
while the Analysis page asked the same question three ways and rolled
answers up the category tree. Two sets of rules for "where does this
question belong" is two pages that can disagree about a learner and
neither able to explain why.

So the rules moved to services/knowledge_groups.py: ancestor roll-up,
article reached through its category, organ system reached through the
symptom keyword. study_tools now asks that service instead of building
the lookups inline, and GET /attempts/{id}/recommendations gives one
session the same Articles / Disciplines / Systems switch. Grouping is its
own call, so changing it does not re-read the question table and the peer
statistics beside it. A running exam ranks nothing — marking it there
would answer the question the exam is asking.

The ungrouped `recommendations` key is gone from the analysis payload
along with the code that built it.

And the document page had no way back. It is reached from the Tools
workbench, which by design has no menu of its own, so leaving it meant
the browser button. It opens onto Tools now, as Tools opens onto
Settings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
Daniel 2026-09-12 03:20:03 +02:00
parent 8a1b518502
commit b80e188eae
13 changed files with 417 additions and 143 deletions

View file

@ -10,6 +10,7 @@ from sqlalchemy.orm import Session
from sqlalchemy import case, func
from app.services.attempt_expiry import active_key, load_saved, progress_key, settle_if_expired
from app.services.knowledge_groups import Grouping, score_rows
from app.services.question_figures import figures_for_questions
from app.services.study_plan_context import mark_block_complete, plan_context_for_quizzes, unmark_block_complete
from app.database import get_db
@ -748,9 +749,6 @@ def quiz_analysis(
"seconds_total": None,
"seconds_per_question": None,
"questions": detail,
# Nothing answered is nothing to recommend from. An empty list says that
# more plainly than a list of every topic at zero per cent would.
"recommendations": [],
"plan": plan_context_for_quizzes(db, current_user.id, [quiz_id]).get(quiz_id),
"not_started": True,
}
@ -824,6 +822,39 @@ def _rows_from_progress(db: Session, user_id: int, attempt: QuizAttempt) -> list
return [_LiveAnswer(question.id, answer, correct) for question, answer, correct in graded]
@router.get("/{attempt_id}/recommendations")
def attempt_recommendations(
attempt_id: int,
group: Literal["articles", "disciplines", "systems"] = "disciplines",
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""This session's weakest topics, asked three ways.
Which reading to go back to, which discipline is weak, which organ system
is weak the same rules the Analysis page uses, from the same place, so
the two cannot disagree about where a question belongs.
A session still running is not marked, so there is nothing to rank: an
exam would be answering the question it is asking.
"""
attempt = db.query(QuizAttempt).filter(
QuizAttempt.id == attempt_id, QuizAttempt.user_id == current_user.id).first()
if not attempt:
raise HTTPException(404, "Attempt not found")
graded = attempt.completed_at is not None or attempt.mode != "exam"
rows = db.query(AttemptAnswer).filter(AttemptAnswer.attempt_id == attempt_id).all() if graded else []
question_ids = [row.question_id for row in rows if row.user_answer]
primary = dict(db.query(Question.id, Question.question_category_id).filter(
Question.id.in_(question_ids)).all()) if question_ids else {}
grouping = Grouping(db, group)
marks = [(row.question_id, primary.get(row.question_id), bool(row.is_correct))
for row in rows if row.user_answer]
return {"group": group, "rows": score_rows(grouping, marks)}
@router.get("/{attempt_id}/analysis")
def attempt_analysis(
attempt_id: int,
@ -917,19 +948,6 @@ def attempt_analysis(
if attempt.completed_at and attempt.started_at:
elapsed = int((attempt.completed_at - attempt.started_at).total_seconds())
# Where to go back to, worst first, counting only what was actually attempted.
by_category: dict[str, list[bool]] = {}
for row in rows:
question = questions.get(row.question_id)
name = categories.get(getattr(question, "question_category_id", None))
if name and row.user_answer and graded:
by_category.setdefault(name, []).append(bool(row.is_correct))
recommendations = sorted(
({"name": name, "correct": sum(marks), "total": len(marks),
"percent": round(100 * sum(marks) / len(marks))} for name, marks in by_category.items()),
key=lambda row: (row["percent"], -row["total"]),
)
return {
"attempt_id": attempt.id,
"quiz_id": attempt.quiz_id,
@ -946,7 +964,9 @@ def attempt_analysis(
"seconds_total": elapsed,
"seconds_per_question": round(sum(timed) / len(timed)) if timed else None,
"questions": detail,
"recommendations": recommendations[:8],
# Where to go back to is its own call now — /recommendations, grouped
# by article, discipline or system — so changing the grouping does not
# re-read the question table and the peer statistics beside it.
# Present when a study-plan block produced this session: the way back
# to the plan and on to the next block.
"plan": plan_context_for_quizzes(db, current_user.id, [attempt.quiz_id]).get(attempt.quiz_id),

View file

@ -10,6 +10,7 @@ from sqlalchemy import text as sa_text
from sqlalchemy.orm import Session
from app.models.exam import Exam
from app.services.knowledge_groups import Grouping
from app.services.quiz_builder import exam_scope_predicate
from app.database import get_db
from app.models.article import Article
@ -228,20 +229,6 @@ READINESS_UNLOCK_ANSWERS = 40
READINESS_PRIOR_ANSWERS = 8
def _category_rollup(categories):
"""Map every category to itself plus all of its ancestors, for roll-up counting."""
parents = {cat.id: cat.parent_id for cat in categories}
ancestry: dict[int, list[int]] = {}
for cid in parents:
chain, cursor, guard = [], cid, 0
while cursor is not None and guard < 12:
chain.append(cursor)
cursor = parents.get(cursor)
guard += 1
ancestry[cid] = chain
return ancestry
@router.get("/completion")
def completion(
days: int | None = Query(None, ge=1, le=3650),
@ -555,10 +542,12 @@ def study_recommendations(
active_exam = db.get(Exam, user.active_exam_id) if getattr(user, "active_exam_id", None) else None
categories = db.query(QuestionCategory).all()
ancestry = _category_rollup(categories)
names = {cat.id: cat.name for cat in categories}
parents = {cat.id: cat.parent_id for cat in categories}
top_level = {cat.id for cat in categories if cat.parent_id is None}
# Membership — which article, discipline or system a question counts
# towards — is the same question a session analysis asks, so both ask it
# of the same place.
grouping = Grouping(db, group, categories)
names = grouping.names
parents = grouping.parents
# ── What the learner has answered ──────────────────────────────
answered_rows = db.query(
@ -578,56 +567,7 @@ def study_recommendations(
*([exam_filter] if exam_filter is not None else []),
).all()
extra_links: dict[int, set[int]] = defaultdict(set)
for question_id, category_id in db.query(
QuestionCategoryLink.question_id, QuestionCategoryLink.category_id).all():
extra_links[question_id].add(category_id)
def categories_for(question_id, primary):
direct = extra_links.get(question_id, set()) | ({primary} if primary else set())
rolled = set()
for cid in direct:
rolled.update(ancestry.get(cid, [cid]))
return rolled
# An article is reached through the category it is filed under.
articles: dict[int, Article] = {}
for article in db.query(Article).filter(Article.status == "published",
Article.category_id.isnot(None)).all():
articles.setdefault(article.category_id, article)
# A question reaches an organ system through the symptom keyword it carries,
# which is filed under one. No question is tagged with a system directly.
# Raw SQL because question_tags has no ORM model; the tags router is the
# same, and one spelling of this table is better than two. Only read when
# the systems grouping is asked for — and only when the table is there,
# since it is not part of the ORM metadata a fresh database is built from.
system_names: dict[int, str] = {}
systems_of_question: dict[int, set[int]] = defaultdict(set)
if group == "systems" and inspect(db.get_bind()).has_table("question_tags"):
for tag_id, name in db.execute(sa_text(
"SELECT id, name FROM question_tags WHERE type = 'system' AND parent_id IS NULL")).all():
system_names[tag_id] = name
if system_names:
for question_id, parent in db.execute(sa_text('''
SELECT l.question_id, t.parent_id
FROM question_tag_links l
JOIN question_tags t ON t.id = l.tag_id
WHERE t.parent_id IS NOT NULL
''')).all():
if parent in system_names:
systems_of_question[question_id].add(parent)
# Three ways to ask the same question of the same answers: which reading to
# go back to, which discipline is weak, which organ system is weak. The
# scoring below does not care which; only the membership differs.
def groups_for(question_id, primary) -> set[int]:
if group == "systems":
return systems_of_question.get(question_id, set())
rolled = categories_for(question_id, primary)
if group == "articles":
return {articles[cid].id for cid in rolled if cid in articles}
return rolled & top_level
groups_for = grouping.keys_for
answered: dict[int, int] = defaultdict(int)
correct: dict[int, int] = defaultdict(int)
@ -658,40 +598,7 @@ def study_recommendations(
overall_accuracy = (total_correct / total_answers) if total_answers else 0.0
unlocked = total_answers >= READINESS_UNLOCK_ANSWERS
# What each row is called, and what it links on to.
article_by_id = {a.id: a for a in articles.values()}
category_of_article = {a.id: cid for cid, a in articles.items()}
def describe(key):
if group == "articles":
article = article_by_id.get(key)
category_id = category_of_article.get(key)
return {
"name": article.title if article else "Untitled",
"parent_name": names.get(parents.get(category_id)) if category_id else None,
"category_id": category_id,
"article_id": key,
"article_title": article.title if article else None,
"system_id": None,
}
if group == "systems":
return {
"name": system_names.get(key, "Unfiled"),
"parent_name": None,
"category_id": None,
"article_id": None,
"article_title": None,
"system_id": key,
}
article = articles.get(key)
return {
"name": names.get(key, "Uncategorized"),
"parent_name": names.get(parents.get(key)),
"category_id": key,
"article_id": article.id if article else None,
"article_title": article.title if article else None,
"system_id": None,
}
describe = grouping.describe
# ── Relevance from the board's own outline ───────────────────────────
# A topic's relevance is the share of the real paper it accounts for, which

View file

@ -0,0 +1,164 @@
"""Three ways to ask the same question of the same answers.
Which reading to go back to (**articles**), which discipline is weak
(**disciplines**), which organ system is weak (**systems**). Only the
membership differs; whatever is being counted a lifetime of answers on the
Analysis page, or the dozen questions in one session the rules for what
belongs where must be the same, or the two pages disagree about a learner in
ways neither can explain.
So the rules live here once, and both callers ask this.
"""
from collections import defaultdict
from sqlalchemy import inspect
from sqlalchemy import text as sa_text
from sqlalchemy.orm import Session
from app.models.article import Article
from app.models.question_category import QuestionCategory, QuestionCategoryLink
GROUPS = ("articles", "disciplines", "systems")
def category_ancestry(categories) -> dict[int, list[int]]:
"""Every category with the chain of parents above it, itself included.
A question filed under "Neonatal jaundice" counts towards "Neonatology"
too, or a discipline would only ever score on questions filed at its own
level which is none of them.
"""
parents = {cat.id: cat.parent_id for cat in categories}
ancestry: dict[int, list[int]] = {}
for cid in parents:
chain, walk, guard = [], cid, 0
# Guarded rather than trusted: a cycle in the tree would hang the page,
# and the depth of a real taxonomy is nowhere near this.
while walk is not None and guard < 20:
chain.append(walk)
walk = parents.get(walk)
guard += 1
ancestry[cid] = chain
return ancestry
class Grouping:
"""The membership rules for one grouping, built once and asked many times."""
def __init__(self, db: Session, group: str, categories=None):
if group not in GROUPS:
raise ValueError(f"unknown grouping: {group}")
self.group = group
self.categories = categories if categories is not None else db.query(QuestionCategory).all()
self.names = {cat.id: cat.name for cat in self.categories}
self.parents = {cat.id: cat.parent_id for cat in self.categories}
self.top_level = {cat.id for cat in self.categories if cat.parent_id is None}
self.ancestry = category_ancestry(self.categories)
self.extra_links: dict[int, set[int]] = defaultdict(set)
for question_id, category_id in db.query(
QuestionCategoryLink.question_id, QuestionCategoryLink.category_id).all():
self.extra_links[question_id].add(category_id)
# An article is reached through the category it is filed under.
self.articles: dict[int, Article] = {}
for article in db.query(Article).filter(Article.status == "published",
Article.category_id.isnot(None)).all():
self.articles.setdefault(article.category_id, article)
self.article_by_id = {a.id: a for a in self.articles.values()}
self.category_of_article = {a.id: cid for cid, a in self.articles.items()}
# A question reaches an organ system through the symptom keyword it
# carries, which is filed under one. No question is tagged with a
# system directly. Raw SQL because question_tags has no ORM model; the
# tags router is the same, and one spelling of this table is better
# than two. Only read for the systems grouping, and only when the table
# is there — it is not part of the ORM metadata a fresh database is
# built from.
self.system_names: dict[int, str] = {}
self.systems_of_question: dict[int, set[int]] = defaultdict(set)
if group == "systems" and inspect(db.get_bind()).has_table("question_tags"):
for tag_id, name in db.execute(sa_text(
"SELECT id, name FROM question_tags WHERE type = 'system' AND parent_id IS NULL")).all():
self.system_names[tag_id] = name
if self.system_names:
for question_id, parent in db.execute(sa_text('''
SELECT l.question_id, t.parent_id
FROM question_tag_links l
JOIN question_tags t ON t.id = l.tag_id
WHERE t.parent_id IS NOT NULL
''')).all():
if parent in self.system_names:
self.systems_of_question[question_id].add(parent)
def categories_for(self, question_id: int, primary: int | None) -> set[int]:
"""Every category a question counts towards, ancestors included."""
direct = self.extra_links.get(question_id, set()) | ({primary} if primary else set())
rolled: set[int] = set()
for cid in direct:
rolled.update(self.ancestry.get(cid, [cid]))
return rolled
def keys_for(self, question_id: int, primary: int | None) -> set[int]:
"""Which rows of this grouping the question belongs to."""
if self.group == "systems":
return self.systems_of_question.get(question_id, set())
rolled = self.categories_for(question_id, primary)
if self.group == "articles":
return {self.articles[cid].id for cid in rolled if cid in self.articles}
return rolled & self.top_level
def describe(self, key: int) -> dict:
"""What one row is called, and what it links on to."""
if self.group == "articles":
article = self.article_by_id.get(key)
category_id = self.category_of_article.get(key)
return {
"name": article.title if article else "Untitled",
"parent_name": self.names.get(self.parents.get(category_id)) if category_id else None,
"category_id": category_id,
"article_id": key,
"article_title": article.title if article else None,
"system_id": None,
}
if self.group == "systems":
return {
"name": self.system_names.get(key, "Unfiled"),
"parent_name": None,
"category_id": None,
"article_id": None,
"article_title": None,
"system_id": key,
}
article = self.articles.get(key)
return {
"name": self.names.get(key, "Uncategorized"),
"parent_name": self.names.get(self.parents.get(key)),
"category_id": key,
"article_id": article.id if article else None,
"article_title": article.title if article else None,
"system_id": None,
}
def score_rows(grouping: Grouping, marks) -> list[dict]:
"""Rank one set of answers by how they went, worst first.
`marks` is (question_id, primary_category_id, was_correct). Used for a
single session, where there is no bank coverage to weigh and no readiness
to shrink just what was answered, and how much of it was right.
"""
answered: dict[int, list[bool]] = defaultdict(list)
for question_id, primary, is_correct in marks:
for key in grouping.keys_for(question_id, primary):
answered[key].append(bool(is_correct))
rows = [
{**grouping.describe(key),
"correct": sum(hits), "total": len(hits),
"percent": round(100 * sum(hits) / len(hits))}
for key, hits in answered.items() if hits
]
# Weakest first, and among equals the one with more answers behind it —
# a 50% on eight questions is a firmer finding than a 50% on two.
rows.sort(key=lambda row: (row["percent"], -row["total"]))
return rows

View file

@ -228,7 +228,8 @@ class QuizAnalysisTests(unittest.TestCase):
self.assertEqual({q["status"] for q in body["questions"]}, {"skipped"})
self.assertEqual([q["position"] for q in body["questions"]],
list(range(1, len(body["questions"]) + 1)))
self.assertEqual(body["recommendations"], [])
# Where to go back to is its own call, grouped three ways.
self.assertNotIn("recommendations", body)
def test_once_sat_the_quiz_address_answers_with_the_real_analysis(self):
quiz_id = self.bank.generate(is_shared=True, category_ids=[1], count=2).json()["id"]
@ -311,8 +312,11 @@ class LiveAnalysisTests(unittest.TestCase):
self.assertEqual(statuses, {"answered", "unanswered"})
self.assertNotIn("correct", statuses)
self.assertNotIn("incorrect", statuses)
# And nothing to go back to yet: a recommendation is a verdict.
self.assertEqual(body["recommendations"], [])
# And nothing to go back to yet: a recommendation is a verdict, and an
# exam still running has not reached one.
recs = self.client.get(f"/attempts/{aid}/recommendations")
self.assertEqual(recs.status_code, 200, recs.text)
self.assertEqual(recs.json()["rows"], [])
def test_a_finished_exam_is_marked(self):
quiz_id = self.bank.generate(is_shared=True, category_ids=[1], count=2).json()["id"]

View file

@ -531,3 +531,87 @@ class ReadinessTests(CompletionTests):
self.assertIsNone(data['score'])
self.assertFalse(data['unlocked'])
self.assertFalse(data['peer']['unlocked'])
class SessionRecommendationTests(CompletionTests):
"""One session's weakest topics, asked the same three ways as the page."""
def setUp(self):
super().setUp()
from app.routers import attempts
self.client.app.include_router(attempts.router, prefix='/attempts')
def rows(self, attempt_id, group='disciplines'):
response = self.client.get(f'/attempts/{attempt_id}/recommendations',
params={'group': group})
self.assertEqual(response.status_code, 200, response.text)
return response.json()['rows']
def sit(self, marks):
"""One completed session answering (question_id, correct) pairs."""
from datetime import datetime
from app.models.attempt import AttemptAnswer, QuizAttempt
attempt = QuizAttempt(user_id=1, quiz_id=1, total_questions=len(marks),
mode='study', score=sum(1 for _, c in marks if c),
completed_at=datetime.utcnow())
self.bank.db.add(attempt)
self.bank.db.flush()
for qid, correct in marks:
self.bank.db.add(AttemptAnswer(
attempt_id=attempt.id, question_id=qid, is_correct=correct,
user_answer='yes' if correct else 'no', seconds_spent=30))
self.bank.db.commit()
return attempt.id
def test_topics_are_ranked_weakest_first(self):
# The fixture tree is Root > Child > Leaf, so everything rolls up to
# Root. A second discipline is made by filing question 2 under the
# other root as well — an extra category link, which counts like a
# primary one.
from app.models.question_category import QuestionCategoryLink
self.bank.db.add(QuestionCategoryLink(question_id=2, category_id=4))
self.bank.db.commit()
aid = self.sit([(1, False), (2, True)])
rows = {row['name']: row for row in self.rows(aid)}
self.assertEqual(rows['Root']['percent'], 50)
self.assertEqual(rows['Empty']['percent'], 100)
# Weakest first: that is the order a learner reads down.
self.assertEqual([row['name'] for row in self.rows(aid)], ['Root', 'Empty'])
def test_a_question_counts_towards_the_discipline_above_it(self):
# Question 3 is filed under Leaf, two levels down. The discipline it
# counts towards is the root, or a discipline would only ever score on
# questions filed at its own level — which is none of them.
aid = self.sit([(3, False)])
rows = self.rows(aid)
self.assertEqual([row['name'] for row in rows], ['Root'])
self.assertEqual(rows[0]['total'], 1)
def test_an_unanswered_question_is_not_a_wrong_topic(self):
from app.models.attempt import AttemptAnswer
aid = self.sit([(1, False)])
self.bank.db.add(AttemptAnswer(attempt_id=aid, question_id=2,
is_correct=False, user_answer='', seconds_spent=0))
self.bank.db.commit()
total = sum(row['total'] for row in self.rows(aid))
self.assertEqual(total, 1)
def test_a_running_exam_is_not_ranked(self):
from app.models.attempt import AttemptAnswer, QuizAttempt
attempt = QuizAttempt(user_id=1, quiz_id=1, mode='exam', total_questions=1)
self.bank.db.add(attempt)
self.bank.db.flush()
self.bank.db.add(AttemptAnswer(attempt_id=attempt.id, question_id=1,
is_correct=True, user_answer='yes', seconds_spent=10))
self.bank.db.commit()
# Marking it here would let a learner see whether an answer was right
# and go back and change it.
self.assertEqual(self.rows(attempt.id), [])
def test_somebody_else_s_session_is_not_readable(self):
aid = self.sit([(1, True)])
self.bank.user = self.bank.peer
self.assertEqual(
self.client.get(f'/attempts/{aid}/recommendations').status_code, 404)

View file

@ -346,13 +346,16 @@ Analysis**, which has three tabs.
### Session Analysis
- [ ] **A rail of latest sessions** on the left, collapsible — mode, title,
answered/total and a progress bar each — with that session's results in
the main area: the same four figures, the same donut, and study
recommendations under Articles / Disciplines / Systems tabs.
- [ ] **Remove the overall figures from the session view.** Now that the
Performance tab holds them, a session page showing lifetime totals
answers a question nobody asked while standing in front of one session.
- [x] **A rail of latest sessions** — done. `AnalysisShell` owns the rail and
the session list for both views. The session's recommendations now carry
the Articles / Disciplines / Systems switch too, from
`GET /attempts/{id}/recommendations` — and the membership rules
(ancestor roll-up, article by category, system by symptom keyword) moved
into `services/knowledge_groups.py` so the lifetime view and one session
cannot disagree about where a question belongs.
- [x] **Remove the overall figures from the session view.** The four figures
there are the session's own. The old ungrouped `recommendations` key is
gone from the analysis payload with them.
### Not us

View file

@ -131,3 +131,15 @@
.ax-rail-show { display: none; }
.ax-main { padding-top: 0; }
}
/* The Articles / Disciplines / Systems switch. Both analysis views carry one
the lifetime picture and a single session so it is styled where they both
already look, keyed on aria-selected because these are tabs. */
.an-group-toggle { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 12px; }
.an-group-toggle button {
border: 1px solid var(--border); background: var(--card-bg); color: var(--text-muted);
border-radius: 20px; padding: 6px 14px; font-size: 0.8rem; font-weight: 600; cursor: pointer;
}
.an-group-toggle button[aria-selected='true'] {
background: var(--primary); border-color: var(--primary); color: var(--primary-fg);
}

View file

@ -44,13 +44,6 @@
}
/* ── Focus areas ──────────────────────────────────────────────────── */
.an-group-toggle { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 12px; }
.an-group-toggle button {
border: 1px solid var(--border); background: var(--card-bg); color: var(--text-muted);
border-radius: 20px; padding: 6px 14px; font-size: 0.8rem; font-weight: 600; cursor: pointer;
}
.an-group-toggle button[aria-pressed='true'] { background: var(--primary); border-color: var(--primary); color: var(--primary-fg); }
.an-focus-head {
display: grid; grid-template-columns: 28px 1fr 90px 90px 110px;
gap: 12px; padding: 0 14px 7px; align-items: center;

View file

@ -488,7 +488,7 @@ export default function AnalysisPage() {
<div className="an-group-toggle" role="tablist" aria-label="Group knowledge profile by">
{[['articles', 'Articles'], ['systems', 'Systems'], ['disciplines', 'Disciplines']].map(([value, label]) => (
<button key={value} role="tab" aria-selected={group === value}
aria-pressed={group === value} onClick={() => setGroup(value)}>{label}</button>
onClick={() => setGroup(value)}>{label}</button>
))}
</div>

View file

@ -46,6 +46,11 @@ export default function AnalysisSessionPage() {
// Ten at a time: a session of forty is a table nobody reads to the end of.
const [page, setPage] = useState(0)
const [repeating, setRepeating] = useState(false)
// Which way to slice this session's answers. Fetched on its own rather than
// with the analysis: changing the grouping should not re-read the question
// table and the peer statistics beside it.
const [group, setGroup] = useState('disciplines')
const [recs, setRecs] = useState(null)
const navigate = useNavigate()
const load = useCallback(() => {
@ -59,6 +64,17 @@ export default function AnalysisSessionPage() {
useEffect(() => { load() }, [load])
useEffect(() => {
const id = data?.attempt_id ?? attemptId
if (!id) return undefined
let live = true
setRecs(null)
api.get(`/attempts/${id}/recommendations`, { params: { group } })
.then(res => { if (live) setRecs(res.data?.rows || []) })
.catch(() => { if (live) setRecs([]) })
return () => { live = false }
}, [data?.attempt_id, attemptId, group])
const rows = useMemo(
() => (data ? [...data.questions].sort(SORTS[sort]) : []), [data, sort])
const PER_PAGE = 10
@ -198,13 +214,36 @@ export default function AnalysisSessionPage() {
<section className="an-card">
<h2>Study recommendations</h2>
{data.recommendations.length === 0 ? (
<p className="an-note">Answer some questions and the weakest topics will show here.</p>
{/* The same answers asked three ways: which reading to go back to,
which discipline is weak, which organ system is weak. The
membership rules are the ones the Analysis page uses one
service answers both, so the two cannot disagree about where a
question belongs. */}
<div className="an-group-toggle" role="tablist" aria-label="Group recommendations by">
{[['articles', 'Articles'], ['disciplines', 'Disciplines'], ['systems', 'Systems']].map(([value, label]) => (
<button key={value} role="tab" aria-selected={group === value}
onClick={() => setGroup(value)}>{label}</button>
))}
</div>
{recs === null ? (
<p className="an-note">Loading</p>
) : recs.length === 0 ? (
<p className="an-note">
{group === 'systems'
? 'None of these questions carries an organ-system keyword yet.'
: group === 'articles'
? 'No published article covers these questions yet.'
: 'Answer some questions and the weakest topics will show here.'}
</p>
) : (
<ul className="an-recs">
{data.recommendations.map(rec => (
<li key={rec.name}>
<span className="an-rec-name">{rec.name}</span>
{recs.map(rec => (
<li key={`${rec.name}-${rec.category_id ?? rec.article_id ?? rec.system_id}`}>
<span className="an-rec-name">
{rec.article_id
? <Link to={`/articles/${rec.article_id}`}>{rec.name}</Link>
: rec.name}
</span>
<span className="an-rec-bar">
<span style={{ width: `${rec.percent}%` }} />
</span>

View file

@ -33,8 +33,25 @@ const SAT = {
recommendations: [], plan: null, not_started: false,
}
const mock = (payload) => api.get.mockImplementation(url => {
const RECS = {
disciplines: [
{ name: 'Neonatology', category_id: 4, article_id: null, system_id: null,
parent_name: null, correct: 1, total: 4, percent: 25 },
{ name: 'Cardiology', category_id: 5, article_id: null, system_id: null,
parent_name: null, correct: 3, total: 4, percent: 75 },
],
articles: [
{ name: 'Kawasaki disease', category_id: 5, article_id: 7, system_id: null,
parent_name: 'Cardiology', correct: 0, total: 2, percent: 0 },
],
systems: [],
}
const mock = (payload, recs = RECS) => api.get.mockImplementation((url, config) => {
if (url === '/quizzes/sessions') return Promise.resolve({ data: SESSIONS })
if (url.includes('/recommendations')) {
return Promise.resolve({ data: { rows: recs[config?.params?.group || 'disciplines'] } })
}
if (url.includes('/analysis')) return Promise.resolve({ data: payload })
return Promise.resolve({ data: [] })
})
@ -220,3 +237,26 @@ describe('a session left part way', () => {
expect(screen.queryByRole('link', { name: 'Repeat session' })).not.toBeInTheDocument()
})
})
it('ranks this session\'s topics, and asks the same answers three ways', async () => {
mock(SAT)
mountAttempt()
const card = (await screen.findByText('Study recommendations')).closest('.an-card')
expect(await within(card).findByText('Neonatology')).toBeInTheDocument()
expect(within(card).getByText('1/4')).toBeInTheDocument()
await userEvent.click(within(card).getByRole('tab', { name: 'Articles' }))
// An article row is a link, because the point of it is to go and read.
expect(await within(card).findByRole('link', { name: 'Kawasaki disease' }))
.toHaveAttribute('href', '/articles/7')
expect(within(card).queryByText('Neonatology')).not.toBeInTheDocument()
})
it('says why a grouping is empty rather than showing nothing', async () => {
mock(SAT)
mountAttempt()
const card = (await screen.findByText('Study recommendations')).closest('.an-card')
await within(card).findByText('Neonatology')
await userEvent.click(within(card).getByRole('tab', { name: 'Systems' }))
expect(await within(card).findByText(/organ-system keyword/)).toBeInTheDocument()
})

View file

@ -1,3 +1,8 @@
.doc-back {
display: inline-block; margin-bottom: 10px;
font-size: 0.85rem; text-decoration: none; color: var(--primary);
}
.doc-back:hover { text-decoration: underline; }
/* A section and what can be done to it
The name took whatever width was left after a button group that would

View file

@ -290,6 +290,9 @@ export default function DocumentDetailPage() {
onClose={() => setActiveJob(null)}
/>
)}
{/* Reached from the Tools workbench, which has no menu of its own so
without this there is no way back but the browser button. */}
<Link className="doc-back" to="/tools"> Tools</Link>
<div className="card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>