fix: one analysis layout, and an unsat session is the same page at zero
Two bugs, one visible cause. `.an-page`, `.an-rail` and four more classes
were defined in both AnalysisPage.css and AnalysisSessionPage.css with
different values — one a 280px grid, the other 260px. Once the two pages
shared a rail both stylesheets loaded together, the later won, and the
content column collapsed to rail width: "General Pediatrics" wrapped one
letter per line and the table headers floated away from their rows.
AnalysisShell now owns the frame and the session list for both views.
The page stylesheets style their content and nothing else.
And a session nobody has sat is no longer a bespoke "nothing here" panel.
GET /attempts/quiz/{id}/analysis answers with the same shape at zero —
0%, 0/20, every row "skipped" — so it is visibly the same page the
learner will see filled in, with a line saying why the figures are zero
and Start below. A part-finished session says how many are outstanding
and offers Resume. Once an attempt exists the quiz address returns the
real analysis, so both ways in reach the same page.
Mobile: below 1000px the rail becomes a band above the content that
starts closed — on a phone the first thing on screen should be the
analysis asked for. Search field is 16px on touch so iOS does not zoom
the page in and refuse to zoom back out; rail rows are 44px targets.
Backend 226/226, frontend 265/265.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
parent
a677b4be23
commit
8f73e0f75b
12 changed files with 671 additions and 255 deletions
|
|
@ -701,6 +701,68 @@ def get_attempt(
|
|||
)
|
||||
|
||||
|
||||
@router.get("/quiz/{quiz_id}/analysis")
|
||||
def quiz_analysis(
|
||||
quiz_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""The analysis for a session addressed by quiz rather than by attempt.
|
||||
|
||||
A session nobody has sat still has an analysis: it is all zeroes and every
|
||||
question is skipped, and that is the honest picture — the same page the
|
||||
learner will see filled in, showing what is missing. Answering it with
|
||||
"nothing to see" made a session that had never been opened look like a
|
||||
different kind of object from one that had.
|
||||
|
||||
Where an attempt exists, its analysis is returned instead, so the page is
|
||||
the same whichever way it was reached.
|
||||
"""
|
||||
quiz = db.query(Quiz).filter(Quiz.id == quiz_id).first()
|
||||
require_quiz_access(db, quiz, current_user)
|
||||
|
||||
attempt = db.query(QuizAttempt).filter(
|
||||
QuizAttempt.quiz_id == quiz_id, QuizAttempt.user_id == current_user.id,
|
||||
).order_by(QuizAttempt.completed_at.isnot(None).desc(),
|
||||
QuizAttempt.started_at.desc()).first()
|
||||
if attempt:
|
||||
return attempt_analysis(attempt.id, db, current_user)
|
||||
|
||||
questions = get_quiz_questions(db, quiz_id)
|
||||
categories = {c.id: c.name for c in db.query(QuestionCategory).all()}
|
||||
detail = [{
|
||||
"position": index,
|
||||
"question_id": question.id,
|
||||
"excerpt": (question.question_text or "")[:120],
|
||||
"status": "skipped",
|
||||
"difficulty": getattr(question, "difficulty", None),
|
||||
"category": categories.get(getattr(question, "question_category_id", None)),
|
||||
"seconds_spent": None,
|
||||
"peer_percent": None,
|
||||
"correct_answer": None,
|
||||
} for index, question in enumerate(questions, start=1)]
|
||||
|
||||
return {
|
||||
"attempt_id": None,
|
||||
"quiz_id": quiz_id,
|
||||
"title": quiz.title,
|
||||
"mode": quiz.mode,
|
||||
"completed_at": None,
|
||||
"total": len(detail),
|
||||
"answered": 0,
|
||||
"score": 0,
|
||||
"percent": 0,
|
||||
"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,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{attempt_id}/analysis")
|
||||
def attempt_analysis(
|
||||
attempt_id: int,
|
||||
|
|
@ -797,4 +859,5 @@ def attempt_analysis(
|
|||
# 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),
|
||||
"not_started": False,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,104 +1,255 @@
|
|||
"""Cross-reference the articles to each other.
|
||||
"""Cross-reference the articles to each other, by id.
|
||||
|
||||
The marker system exists — `[[7|Febrile seizures]]` resolves by id, survives a
|
||||
rename, and shows a preview on hover — and not one article used it. Every article
|
||||
was written in isolation, so a piece on croup mentions stridor and epiglottitis
|
||||
and offers no way to reach either.
|
||||
rename and shows a preview on hover. This reads what is written and applies it.
|
||||
|
||||
This reads what is already written and links it: where an article's prose names
|
||||
another article, the first mention becomes a link. By id, so a later rename
|
||||
cannot break it.
|
||||
The first version linked the first mention in every *section*, which produced
|
||||
3,560 links dominated by a handful of hub terms: Seizures 155 times, Sepsis
|
||||
104, Meningitis 82. A reader does not need "seizures may occur" to be a link in
|
||||
every article that mentions seizures; that is noise, and noise trains people to
|
||||
stop clicking. The rule now:
|
||||
|
||||
Deliberately conservative, because a wrong link is worse than a missing one:
|
||||
1. **First mention per view, not per section.** Short, Long and Clinical are
|
||||
read separately so each earns its own first link, but a term is linked
|
||||
once within a view rather than once per heading.
|
||||
2. **Lists are jump lists; prose is not.** In a differential, causes or
|
||||
complications list every distinct condition keeps its link — that is the
|
||||
one place a reader wants ten links in a row. In running prose, only the
|
||||
first mention.
|
||||
3. **Hub terms link from lists only.** A title mentioned across more than
|
||||
HUB_SHARE of all articles is too general to be worth a jump from prose.
|
||||
It still links as a list item, where it is something you might pick.
|
||||
4. **Specific beats general.** Longest title first, and a marker once made is
|
||||
protected, so "Otitis media with effusion" cannot be re-cut into
|
||||
"Otitis media".
|
||||
5. **Never** inside a heading, table, code span, fenced block, existing link
|
||||
or marker; never an article to itself; never a title under MIN_TITLE
|
||||
characters, which collide.
|
||||
6. **The educator wins.** A link whose label differs from the target's title
|
||||
was written by hand and is left alone — stripping and re-linking only ever
|
||||
touches links this script could have made. NEVER_LINK holds terms that
|
||||
should not auto-link at all.
|
||||
|
||||
* only the first mention in a section, so prose is not peppered with the same
|
||||
link five times;
|
||||
* whole words only, case-insensitively, longest title first — "Otitis media
|
||||
with effusion" wins over "Otitis media" where both would match;
|
||||
* never inside an existing link, a marker, a heading, a code span or a table;
|
||||
* never an article linking to itself;
|
||||
* titles under four characters are skipped, since short ones collide.
|
||||
Re-runnable: --apply strips the links it owns and reapplies the rule, so
|
||||
changing the rule or the prose does not leave the old pass behind.
|
||||
|
||||
docker compose exec backend python -m scripts.link_articles
|
||||
docker compose exec backend python -m scripts.link_articles --apply
|
||||
docker compose exec backend python -m scripts.link_articles --strip --apply
|
||||
"""
|
||||
import re
|
||||
import sys
|
||||
from collections import Counter, defaultdict
|
||||
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
|
||||
from app.database import SessionLocal
|
||||
from app.models.article import Article
|
||||
|
||||
MIN_TITLE = 4
|
||||
# Only the body of a section, never a heading, a link, a marker or code.
|
||||
SKIP = re.compile(r"(\[\[[^\]]*\]\]|\[[^\]]*\]\([^)]*\)|`[^`]*`|^\s{0,3}#{1,6}.*$|^\s*\|.*$)", re.M)
|
||||
#: A title mentioned in more than this share of articles links from lists only.
|
||||
HUB_SHARE = 0.25
|
||||
#: Terms that are never worth a jump, however specific the match looks.
|
||||
NEVER_LINK = {"history", "examination", "management", "treatment", "prognosis"}
|
||||
|
||||
MARKER = re.compile(r"\[\[(\d+)\|([^\]]+)\]\]")
|
||||
#: Spans within a line that must not be linked into.
|
||||
PROTECTED = re.compile(r"(\[\[[^\]]*\]\]|\[[^\]]*\]\([^)]*\)|`[^`]*`)")
|
||||
HEADING = re.compile(r"^\s{0,3}#{1,6}\s")
|
||||
TABLE = re.compile(r"^\s*\|")
|
||||
FENCE = re.compile(r"^\s*(```|~~~)")
|
||||
LIST_ITEM = re.compile(r"^\s*([-*+]|\d+[.)])\s")
|
||||
|
||||
LEAD = "__lead__" # summary and the whole-article introduction
|
||||
|
||||
|
||||
def link_text(text: str, titles: list[tuple[str, int, str]], self_id: int) -> tuple[str, int]:
|
||||
"""Link the first mention of each other article. Returns (text, count)."""
|
||||
def word_pattern(title: str) -> re.Pattern:
|
||||
"""Whole-word, case-insensitive, and never biting into an existing marker."""
|
||||
return re.compile(rf"(?<![\w\[|]){re.escape(title)}(?![\w\]])", re.I)
|
||||
|
||||
|
||||
def strip_owned(text: str, titles_by_id: dict[int, str]) -> tuple[str, int]:
|
||||
"""Remove the links this script owns, leaving the words behind.
|
||||
|
||||
A link is ours when its label is the target's title. Anything else — a link
|
||||
an educator wrote as `[[7|this condition]]` — is left exactly as it is.
|
||||
"""
|
||||
if not text:
|
||||
return text, 0
|
||||
removed = 0
|
||||
|
||||
def drop(match: re.Match) -> str:
|
||||
nonlocal removed
|
||||
title = titles_by_id.get(int(match.group(1)))
|
||||
label = match.group(2)
|
||||
if title and label.strip().lower() == title.strip().lower():
|
||||
removed += 1
|
||||
return label
|
||||
return match.group(0)
|
||||
|
||||
return MARKER.sub(drop, text), removed
|
||||
|
||||
|
||||
def link_text(text, targets, self_id, seen, hubs):
|
||||
"""Link `text` under the rule. `seen` is shared across one view and mutated.
|
||||
|
||||
`targets` is (lowered title, id, is_hub), longest first.
|
||||
"""
|
||||
if not text:
|
||||
return text, 0
|
||||
|
||||
# Carve out everything that must not be touched, link the rest, put it back.
|
||||
holes: list[str] = []
|
||||
out, linked, in_fence = [], 0, False
|
||||
list_scope: set[int] | None = None
|
||||
|
||||
def stash(match: re.Match) -> str:
|
||||
holes.append(match.group(0))
|
||||
return f"\x00{len(holes) - 1}\x00"
|
||||
|
||||
working = SKIP.sub(stash, text)
|
||||
|
||||
linked = 0
|
||||
for lowered, article_id, display in titles:
|
||||
if article_id == self_id:
|
||||
for line in text.split("\n"):
|
||||
if FENCE.match(line):
|
||||
in_fence = not in_fence
|
||||
out.append(line)
|
||||
continue
|
||||
pattern = re.compile(rf"(?<![\w\[]){re.escape(lowered)}(?![\w\]])", re.I)
|
||||
match = pattern.search(working)
|
||||
if not match:
|
||||
if in_fence or HEADING.match(line) or TABLE.match(line) or not line.strip():
|
||||
# A blank line ends a list block, so the next list starts fresh.
|
||||
if not line.strip():
|
||||
list_scope = None
|
||||
out.append(line)
|
||||
continue
|
||||
# Keep the author's own casing; only the target is decided here.
|
||||
working = (working[:match.start()]
|
||||
+ f"[[{article_id}|{match.group(0)}]]"
|
||||
+ working[match.end():])
|
||||
linked += 1
|
||||
|
||||
restored = re.sub(r"\x00(\d+)\x00", lambda m: holes[int(m.group(1))], working)
|
||||
return restored, linked
|
||||
is_item = bool(LIST_ITEM.match(line))
|
||||
if is_item:
|
||||
if list_scope is None:
|
||||
list_scope = set()
|
||||
else:
|
||||
list_scope = None
|
||||
|
||||
# Carve out spans that must not be linked into, link the rest, restore.
|
||||
holes: list[str] = []
|
||||
|
||||
def stash(match: re.Match) -> str:
|
||||
holes.append(match.group(0))
|
||||
return f"\x00{len(holes) - 1}\x00"
|
||||
|
||||
working = PROTECTED.sub(stash, line)
|
||||
|
||||
for lowered, article_id, is_hub in targets:
|
||||
if article_id == self_id or lowered in NEVER_LINK:
|
||||
continue
|
||||
if is_item:
|
||||
# Rule 2: a jump list links each distinct target once per list.
|
||||
if article_id in list_scope:
|
||||
continue
|
||||
else:
|
||||
# Rule 1 and 3: prose links a target once per view, and never
|
||||
# links a hub term at all.
|
||||
if is_hub or article_id in seen:
|
||||
continue
|
||||
|
||||
match = word_pattern(lowered).search(working)
|
||||
if not match:
|
||||
continue
|
||||
# Keep the author's casing; only the target is decided here. The
|
||||
# finished marker is stashed so a shorter title cannot re-cut it.
|
||||
holes.append(f"[[{article_id}|{match.group(0)}]]")
|
||||
working = f"{working[:match.start()]}\x00{len(holes) - 1}\x00{working[match.end():]}"
|
||||
linked += 1
|
||||
seen.add(article_id)
|
||||
if is_item:
|
||||
list_scope.add(article_id)
|
||||
|
||||
out.append(re.sub(r"\x00(\d+)\x00", lambda m: holes[int(m.group(1))], working))
|
||||
|
||||
return "\n".join(out), linked
|
||||
|
||||
|
||||
def main():
|
||||
def scopes(article) -> dict[str, list[dict]]:
|
||||
"""Sections grouped by the view they belong to; each view links independently."""
|
||||
grouped: dict[str, list[dict]] = defaultdict(list)
|
||||
for section in article.sections or []:
|
||||
grouped[section.get("variant") or "long"].append(section)
|
||||
return grouped
|
||||
|
||||
|
||||
def main() -> int:
|
||||
apply_changes = "--apply" in sys.argv
|
||||
strip_only = "--strip" in sys.argv
|
||||
db = SessionLocal()
|
||||
try:
|
||||
articles = db.query(Article).all()
|
||||
# Longest first: "Otitis media with effusion" must win over "Otitis media".
|
||||
titles = sorted(
|
||||
((a.title.lower(), a.id, a.title) for a in articles if len(a.title or "") >= MIN_TITLE),
|
||||
titles_by_id = {a.id: a.title for a in articles}
|
||||
|
||||
# Strip first, always: the rule is applied to clean prose so a re-run
|
||||
# cannot layer a new pass on top of an old one.
|
||||
stripped = 0
|
||||
for article in articles:
|
||||
for section in article.sections or []:
|
||||
section["content"], n = strip_owned(section.get("content"), titles_by_id)
|
||||
stripped += n
|
||||
article.summary, n = strip_owned(article.summary, titles_by_id)
|
||||
stripped += n
|
||||
article.content, n = strip_owned(article.content, titles_by_id)
|
||||
stripped += n
|
||||
print(f" existing auto-links stripped: {stripped}")
|
||||
|
||||
if strip_only:
|
||||
if apply_changes:
|
||||
for article in articles:
|
||||
flag_modified(article, "sections")
|
||||
db.commit()
|
||||
print(" stripped and committed; prose is clean.")
|
||||
else:
|
||||
print("\n Re-run with --apply.")
|
||||
return 0
|
||||
|
||||
candidates = sorted(
|
||||
((a.title.lower(), a.id) for a in articles if len(a.title or "") >= MIN_TITLE),
|
||||
key=lambda row: -len(row[0]),
|
||||
)
|
||||
print(f" articles: {len(articles)} linkable titles: {len(titles)}")
|
||||
|
||||
# How widely each title is mentioned decides whether it is a hub. Counted
|
||||
# over the corpus as written, not over the links the last run happened
|
||||
# to make, so the threshold does not drift with its own output.
|
||||
bodies = {}
|
||||
for article in articles:
|
||||
parts = [article.summary or "", article.content or ""]
|
||||
parts += [s.get("content") or "" for s in (article.sections or [])]
|
||||
bodies[article.id] = "\n".join(parts).lower()
|
||||
|
||||
mentions: Counter[int] = Counter()
|
||||
for lowered, article_id in candidates:
|
||||
pattern = word_pattern(lowered)
|
||||
for other_id, body in bodies.items():
|
||||
if other_id != article_id and lowered in body and pattern.search(body):
|
||||
mentions[article_id] += 1
|
||||
|
||||
cutoff = max(2, int(HUB_SHARE * len(articles)))
|
||||
hubs = {aid for aid, n in mentions.items() if n > cutoff}
|
||||
targets = [(lowered, aid, aid in hubs) for lowered, aid in candidates]
|
||||
print(f" articles: {len(articles)} linkable titles: {len(candidates)}")
|
||||
print(f" hub cutoff: mentioned in more than {cutoff} articles -> {len(hubs)} hubs, list-only")
|
||||
for aid in sorted(hubs, key=lambda a: -mentions[a])[:12]:
|
||||
print(f" {mentions[aid]:4d} {titles_by_id[aid]}")
|
||||
|
||||
touched = links = 0
|
||||
for article in articles:
|
||||
sections = article.sections or []
|
||||
changed = False
|
||||
for section in sections:
|
||||
body, count = link_text(section.get("content"), titles, article.id)
|
||||
if count:
|
||||
section["content"] = body
|
||||
links += count
|
||||
changed = True
|
||||
summary, count = link_text(article.summary, titles, article.id)
|
||||
if count:
|
||||
article.summary = summary
|
||||
links += count
|
||||
changed = True
|
||||
# The lead is its own scope: it is shown above every view.
|
||||
seen: set[int] = set()
|
||||
article.summary, n = link_text(article.summary, targets, article.id, seen, hubs)
|
||||
links += n
|
||||
changed |= bool(n)
|
||||
article.content, n = link_text(article.content, targets, article.id, seen, hubs)
|
||||
links += n
|
||||
changed |= bool(n)
|
||||
|
||||
for _variant, sections in scopes(article).items():
|
||||
seen = set()
|
||||
for section in sections:
|
||||
section["content"], n = link_text(
|
||||
section.get("content"), targets, article.id, seen, hubs)
|
||||
links += n
|
||||
changed |= bool(n)
|
||||
if changed:
|
||||
touched += 1
|
||||
if apply_changes:
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
|
||||
article.sections = sections
|
||||
flag_modified(article, "sections")
|
||||
if apply_changes:
|
||||
flag_modified(article, "sections")
|
||||
|
||||
print(f" articles gaining links: {touched}")
|
||||
print(f" links added : {links}")
|
||||
|
|
|
|||
|
|
@ -188,3 +188,48 @@ class SessionLifecycleTests(unittest.TestCase):
|
|||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class QuizAnalysisTests(unittest.TestCase):
|
||||
"""A session addressed by quiz: the same page at zero."""
|
||||
|
||||
def setUp(self):
|
||||
self.bank = fixtures.BuilderTests()
|
||||
self.bank.setUp()
|
||||
self.client = self.bank.client
|
||||
self.db = self.bank.db
|
||||
self.client.app.include_router(attempts.router, prefix="/attempts")
|
||||
self.bank.user = self.bank.owner
|
||||
|
||||
def tearDown(self):
|
||||
self.bank.tearDown()
|
||||
|
||||
def test_a_quiz_nobody_has_sat_answers_with_zeroes_and_every_question_skipped(self):
|
||||
quiz_id = self.bank.generate(is_shared=True, category_ids=[1], count=2).json()["id"]
|
||||
body = self.client.get(f"/attempts/quiz/{quiz_id}/analysis").json()
|
||||
self.assertTrue(body["not_started"])
|
||||
self.assertIsNone(body["attempt_id"])
|
||||
self.assertEqual((body["answered"], body["score"], body["percent"]), (0, 0, 0))
|
||||
self.assertEqual(body["total"], len(body["questions"]))
|
||||
self.assertTrue(body["questions"])
|
||||
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"], [])
|
||||
|
||||
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"]
|
||||
self.db.add(QuizAttempt(id=910, quiz_id=quiz_id, user_id=self.bank.owner.id, mode="learning",
|
||||
total_questions=1, score=1, started_at=datetime(2026, 5, 1),
|
||||
completed_at=datetime(2026, 5, 1, 1)))
|
||||
self.db.add(AttemptAnswer(attempt_id=910, question_id=1, user_answer="A", is_correct=True))
|
||||
self.db.commit()
|
||||
body = self.client.get(f"/attempts/quiz/{quiz_id}/analysis").json()
|
||||
self.assertFalse(body["not_started"])
|
||||
self.assertEqual(body["attempt_id"], 910)
|
||||
self.assertEqual(body["percent"], 100)
|
||||
|
||||
def test_a_quiz_the_learner_cannot_see_is_refused(self):
|
||||
quiz_id = self.bank.generate(is_shared=False, category_ids=[1], count=2).json()["id"]
|
||||
self.bank.user = self.bank.peer
|
||||
self.assertEqual(self.client.get(f"/attempts/quiz/{quiz_id}/analysis").status_code, 403)
|
||||
|
|
|
|||
117
frontend/src/components/AnalysisShell.css
Normal file
117
frontend/src/components/AnalysisShell.css
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
/* The frame shared by the overall analysis and a single session's.
|
||||
*
|
||||
* It exists because both pages had their own `.an-page` and `.an-rail` rules —
|
||||
* one a 280px grid, the other a 260px grid — and once the two pages shared a
|
||||
* rail both stylesheets loaded together and the later one won. The content
|
||||
* column collapsed to rail width and headings wrapped one letter per line.
|
||||
* One owner for the layout, and the page stylesheets style only their content.
|
||||
*/
|
||||
|
||||
.ax-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 288px minmax(0, 1fr);
|
||||
gap: 0 28px;
|
||||
align-items: start;
|
||||
/* The rail bleeds to the window edge; the content keeps a readable measure. */
|
||||
margin-inline: calc(50% - 50vw);
|
||||
padding-inline: max(16px, calc(50vw - 660px));
|
||||
}
|
||||
.ax-layout.is-collapsed { grid-template-columns: 0 minmax(0, 1fr); gap: 0; }
|
||||
|
||||
.ax-main { min-width: 0; padding: 8px 0 48px; }
|
||||
|
||||
/* ── Rail ─────────────────────────────────────────────────────────── */
|
||||
.ax-rail {
|
||||
position: sticky; top: 0; height: 100vh;
|
||||
display: flex; flex-direction: column; min-width: 0;
|
||||
background: var(--card-bg); border-right: 1px solid var(--border);
|
||||
/* Reach the left edge of the window without leaving the grid. */
|
||||
margin-left: calc(0px - max(16px, calc(50vw - 660px)));
|
||||
padding-left: max(16px, calc(50vw - 660px));
|
||||
box-sizing: content-box;
|
||||
}
|
||||
.ax-layout.is-collapsed .ax-rail { display: none; }
|
||||
|
||||
.ax-rail-head {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||||
padding: 14px 16px; border-bottom: 1px solid var(--border); flex-shrink: 0;
|
||||
}
|
||||
.ax-rail-head h2 { margin: 0; font-size: 0.95rem; font-weight: 650; white-space: nowrap; }
|
||||
.ax-rail-head button {
|
||||
flex-shrink: 0; width: 30px; height: 30px; min-width: 30px;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
background: none; border: 1px solid var(--border); border-radius: 50%;
|
||||
cursor: pointer; color: var(--text-muted); font-size: 1rem; line-height: 1;
|
||||
}
|
||||
.ax-rail-head button:hover { border-color: var(--primary); color: var(--primary); }
|
||||
|
||||
/* Reopening when collapsed: a tab against the left edge, not a lost control. */
|
||||
.ax-rail-show {
|
||||
position: fixed; left: 0; top: 96px; z-index: 20;
|
||||
padding: 10px 12px; font-size: 0.78rem; font-weight: 650;
|
||||
background: var(--card-bg); color: var(--text-muted);
|
||||
border: 1px solid var(--border); border-left: 0;
|
||||
border-radius: 0 10px 10px 0; cursor: pointer;
|
||||
}
|
||||
.ax-rail-show:hover { color: var(--primary); border-color: var(--primary); }
|
||||
|
||||
.ax-rail-all {
|
||||
display: flex; flex-direction: column; gap: 3px; padding: 13px 16px;
|
||||
text-decoration: none; color: var(--text); border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ax-rail-all:hover { background: var(--bg); }
|
||||
.ax-rail-all strong { font-size: 0.88rem; font-weight: 650; }
|
||||
.ax-rail-all span { font-size: 0.76rem; color: var(--text-muted); }
|
||||
|
||||
.ax-rail-search {
|
||||
flex-shrink: 0; margin: 10px 16px; padding: 8px 11px;
|
||||
/* 16px on touch: iOS zooms the page in on any smaller font when a field
|
||||
takes focus, and never zooms back out. */
|
||||
font-size: 16px; font-family: inherit;
|
||||
border: 1px solid var(--border); border-radius: 8px;
|
||||
background: var(--input-bg); color: var(--text);
|
||||
}
|
||||
@media (min-width: 900px) { .ax-rail-search { font-size: 0.84rem; } }
|
||||
|
||||
.ax-rail-list { list-style: none; margin: 0; padding: 0; flex: 1; overflow-y: auto; -webkit-overflow-scrolling: touch; }
|
||||
.ax-rail-list > li { border-bottom: 1px solid var(--border); }
|
||||
.ax-rail-list > li:last-child { border-bottom: 0; }
|
||||
.ax-rail-list a {
|
||||
display: flex; flex-direction: column; gap: 7px; padding: 13px 16px;
|
||||
text-decoration: none; color: var(--text); min-height: 44px; /* touch target */
|
||||
}
|
||||
.ax-rail-list a:hover { background: var(--bg); }
|
||||
.ax-rail-title { font-size: 0.84rem; line-height: 1.45; overflow-wrap: anywhere; }
|
||||
.ax-rail-title strong { font-weight: 650; }
|
||||
.ax-rail-empty { margin: 0; padding: 14px 16px; font-size: 0.83rem; color: var(--text-muted); }
|
||||
|
||||
/* Which one you are reading. */
|
||||
.ax-rail-all.active, .ax-rail-list a.active {
|
||||
background: var(--option-sel-bg); box-shadow: inset 3px 0 0 var(--primary);
|
||||
}
|
||||
.ax-rail-all.active strong, .ax-rail-list a.active .ax-rail-title { color: var(--primary); }
|
||||
|
||||
/* ── Narrow ───────────────────────────────────────────────────────── */
|
||||
/* Below 1000px the rail stops being a column. It becomes a band above the
|
||||
content that starts closed, because on a phone the first thing on screen
|
||||
should be the analysis asked for, not a list of every other session. */
|
||||
@media (max-width: 1000px) {
|
||||
.ax-layout, .ax-layout.is-collapsed {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
margin-inline: 0; padding-inline: 0; gap: 0;
|
||||
}
|
||||
.ax-rail {
|
||||
position: static; height: auto; max-height: 58vh;
|
||||
margin-left: 0; padding-left: 0;
|
||||
border-right: 0; border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.ax-layout.is-collapsed .ax-rail { display: flex; max-height: none; }
|
||||
.ax-layout.is-collapsed .ax-rail-list,
|
||||
.ax-layout.is-collapsed .ax-rail-all,
|
||||
.ax-layout.is-collapsed .ax-rail-search { display: none; }
|
||||
.ax-rail-head { border-bottom: 0; }
|
||||
.ax-rail-show { display: none; }
|
||||
.ax-main { padding-top: 0; }
|
||||
}
|
||||
46
frontend/src/components/AnalysisShell.jsx
Normal file
46
frontend/src/components/AnalysisShell.jsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import SessionRail from './SessionRail'
|
||||
import api from '../api/client'
|
||||
import './AnalysisShell.css'
|
||||
|
||||
/**
|
||||
* The frame both analysis views sit in: the session rail, then the content.
|
||||
*
|
||||
* It owns the session list as well as the layout, because both pages showed
|
||||
* the same list and each fetched and sliced it differently — one took the
|
||||
* latest twelve, the other all of them.
|
||||
*
|
||||
* The rail starts closed on a narrow screen: the first thing on a phone should
|
||||
* be the analysis that was asked for, not a list of every other session.
|
||||
*/
|
||||
export default function AnalysisShell({ children }) {
|
||||
const [sessions, setSessions] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [open, setOpen] = useState(() => (
|
||||
typeof window === 'undefined' || !window.matchMedia
|
||||
? true
|
||||
: window.matchMedia('(min-width: 1001px)').matches
|
||||
))
|
||||
|
||||
useEffect(() => {
|
||||
let live = true
|
||||
api.get('/quizzes/sessions')
|
||||
.then(res => { if (live) setSessions(Array.isArray(res.data) ? res.data : []) })
|
||||
.catch(() => { if (live) setSessions([]) })
|
||||
.finally(() => { if (live) setLoading(false) })
|
||||
return () => { live = false }
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className={`ax-layout${open ? '' : ' is-collapsed'}`}>
|
||||
<SessionRail sessions={sessions} open={open} loading={loading}
|
||||
onToggle={() => setOpen(v => !v)} />
|
||||
{!open && (
|
||||
<button type="button" className="ax-rail-show" onClick={() => setOpen(true)}>
|
||||
› Sessions
|
||||
</button>
|
||||
)}
|
||||
<main className="ax-main">{children}</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
/* Additions to the rail shared by the overall analysis and a single session.
|
||||
The frame itself (.an-rail, .an-rail-head, .an-rail-list) is defined in
|
||||
AnalysisPage.css, which both views already load. */
|
||||
|
||||
/* The overall picture, pinned above the individual sessions and separated from
|
||||
them — it is the parent of the list, not the first row of it. */
|
||||
.an-rail-all {
|
||||
display: flex; flex-direction: column; gap: 3px;
|
||||
padding: 12px 14px; text-decoration: none; color: var(--text);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.an-rail-all:hover { background: var(--bg); }
|
||||
.an-rail-all strong { font-size: .85rem; font-weight: 650; }
|
||||
.an-rail-all span { font-size: .75rem; color: var(--text-muted); }
|
||||
|
||||
/* Which session you are reading. Without this the rail gives no clue, and on a
|
||||
list of fifteen that matters more than the hover state does. */
|
||||
.an-rail-all.active,
|
||||
.an-rail-list a.active {
|
||||
background: var(--option-sel-bg);
|
||||
box-shadow: inset 3px 0 0 var(--primary);
|
||||
}
|
||||
.an-rail-all.active strong,
|
||||
.an-rail-list a.active .an-rail-title { color: var(--primary); }
|
||||
|
||||
.an-rail-search {
|
||||
width: calc(100% - 20px); margin: 10px; padding: 7px 10px;
|
||||
/* 16px on touch: iOS zooms the whole page in on any smaller font when a
|
||||
field takes focus, and never zooms back out. */
|
||||
font-size: .82rem; font-family: inherit;
|
||||
border: 1px solid var(--border); border-radius: 7px;
|
||||
background: var(--input-bg); color: var(--text);
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.an-rail-search { font-size: 16px; }
|
||||
}
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
import { useMemo, useState } from 'react'
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import SessionProgress from './SessionProgress'
|
||||
import './SessionRail.css'
|
||||
|
||||
/**
|
||||
* The list of sessions, alongside whatever session you are reading.
|
||||
|
|
@ -12,7 +11,8 @@ import './SessionRail.css'
|
|||
* which listed the same rows a second time under a different heading.
|
||||
*
|
||||
* Nothing is truncated. A learner asking "what have I done" wants the whole
|
||||
* answer, and the search box is what narrows it.
|
||||
* answer, and the search box is what narrows it. Styling lives in
|
||||
* AnalysisShell.css, which owns the frame this sits in.
|
||||
*/
|
||||
export default function SessionRail({ sessions, open, onToggle, loading }) {
|
||||
const [query, setQuery] = useState('')
|
||||
|
|
@ -24,9 +24,9 @@ export default function SessionRail({ sessions, open, onToggle, loading }) {
|
|||
}, [sessions, query])
|
||||
|
||||
return (
|
||||
<aside className="an-rail">
|
||||
<div className="an-rail-head">
|
||||
<h2>Sessions</h2>
|
||||
<aside className="ax-rail" aria-label="Sessions">
|
||||
<div className="ax-rail-head">
|
||||
<h2>Sessions{sessions.length > 0 && !open ? ` (${sessions.length})` : ''}</h2>
|
||||
<button type="button" aria-label={open ? 'Hide sessions' : 'Show sessions'}
|
||||
aria-expanded={open} onClick={onToggle}>{open ? '‹' : '›'}</button>
|
||||
</div>
|
||||
|
|
@ -35,24 +35,24 @@ export default function SessionRail({ sessions, open, onToggle, loading }) {
|
|||
<>
|
||||
{/* `end` so this only lights up on /sessions itself, not on every
|
||||
session underneath it. */}
|
||||
<NavLink to="/sessions" end className="an-rail-all">
|
||||
<NavLink to="/sessions" end className="ax-rail-all">
|
||||
<strong>Your overall analysis</strong>
|
||||
<span>Everything you have practised</span>
|
||||
</NavLink>
|
||||
|
||||
{sessions.length > 6 && (
|
||||
<input className="an-rail-search" type="search" value={query}
|
||||
<input className="ax-rail-search" type="search" value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
placeholder="Search sessions" aria-label="Search sessions" />
|
||||
)}
|
||||
|
||||
{loading ? <p className="an-rail-empty">Loading…</p>
|
||||
{loading ? <p className="ax-rail-empty">Loading…</p>
|
||||
: shown.length === 0 ? (
|
||||
<p className="an-rail-empty">
|
||||
<p className="ax-rail-empty">
|
||||
{sessions.length === 0 ? 'No sessions yet.' : 'Nothing matches that.'}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="an-rail-list">
|
||||
<ul className="ax-rail-list">
|
||||
{shown.map(row => (
|
||||
<li key={row.quiz_id}>
|
||||
{/* Never launches the test. A session you have not sat
|
||||
|
|
@ -61,7 +61,7 @@ export default function SessionRail({ sessions, open, onToggle, loading }) {
|
|||
into a 240-question exam. */}
|
||||
<NavLink to={row.last_attempt_id
|
||||
? `/sessions/${row.last_attempt_id}` : `/sessions/q/${row.quiz_id}`}>
|
||||
<span className="an-rail-title">
|
||||
<span className="ax-rail-title">
|
||||
<strong>{row.mode === 'learning' ? 'Study mode:' : 'Exam mode:'}</strong> {row.title}
|
||||
</span>
|
||||
<SessionProgress answered={row.answered} total={row.total}
|
||||
|
|
|
|||
|
|
@ -1,40 +1,6 @@
|
|||
/* Session rail beside the analysis, as in a Qbank analysis view. */
|
||||
/* The rail runs the full height of the viewport against the left edge, rather
|
||||
than sitting in a card, so a long session list is one continuous column. */
|
||||
.an-layout { display: grid; grid-template-columns: 280px minmax(0, 1fr); gap: 28px; align-items: start; }
|
||||
.an-layout.rail-closed { grid-template-columns: 48px minmax(0, 1fr); }
|
||||
.an-rail {
|
||||
position: sticky; top: 0; height: 100vh; display: flex; flex-direction: column;
|
||||
background: var(--card-bg); border-right: 1px solid var(--border);
|
||||
margin-left: calc(50% - 50vw); padding-left: max(0px, calc(50vw - 50%));
|
||||
box-sizing: content-box;
|
||||
}
|
||||
.an-rail-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 12px 14px; border-bottom: 1px solid var(--border); }
|
||||
.an-rail-head h2 { margin: 0; font-size: .92rem; font-weight: 650; white-space: nowrap; }
|
||||
.an-layout.rail-closed .an-rail-head h2 { display: none; }
|
||||
.an-rail-head button { background: none; border: none; cursor: pointer; color: var(--text-muted); font-size: 1.1rem; line-height: 1; padding: 2px 4px; }
|
||||
.an-rail-list { list-style: none; margin: 0; padding: 0; flex: 1; overflow-y: auto; }
|
||||
.an-rail-list > li { border-bottom: 1px solid var(--border); }
|
||||
.an-rail-list > li:last-child { border-bottom: 0; }
|
||||
.an-rail-list a { display: flex; flex-direction: column; gap: 7px; padding: 12px 14px; text-decoration: none; color: var(--text); }
|
||||
.an-rail-list a:hover { background: var(--bg); }
|
||||
.an-rail-title { font-size: .84rem; line-height: 1.4; overflow-wrap: anywhere; }
|
||||
.an-rail-title strong { font-weight: 650; }
|
||||
.an-rail-empty { margin: 0; padding: 12px 14px; font-size: .82rem; color: var(--text-muted); }
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.an-layout, .an-layout.rail-closed { grid-template-columns: 1fr; }
|
||||
.an-rail {
|
||||
position: static; height: auto; margin-left: 0; padding-left: 0;
|
||||
border-right: 0; border: 1px solid var(--border); border-radius: 12px;
|
||||
}
|
||||
.an-rail-list { max-height: 260px; }
|
||||
}
|
||||
|
||||
/* Performance analysis — readiness summary and ranked focus areas.
|
||||
Mobile-first: the focus table collapses to stacked cards under 760px. */
|
||||
|
||||
.an-page { max-width: 1080px; margin: 0 auto; }
|
||||
The layout and the session rail belong to AnalysisShell.css. */
|
||||
.an-page { min-width: 0; }
|
||||
|
||||
.an-header { margin-bottom: 12px; }
|
||||
.an-header h1 { margin: 0 0 4px; font-size: 1.35rem; }
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useState, useEffect, useCallback } from 'react'
|
|||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import api from '../api/client'
|
||||
import CategoryPerformance from '../components/CategoryPerformance'
|
||||
import SessionRail from '../components/SessionRail'
|
||||
import AnalysisShell from '../components/AnalysisShell'
|
||||
import './AnalysisPage.css'
|
||||
|
||||
const STATUS_LABEL = { focus: 'Focus area', proficient: 'Proficient', no_data: 'No data yet' }
|
||||
|
|
@ -69,9 +69,6 @@ export default function AnalysisPage() {
|
|||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [count, setCount] = useState(10)
|
||||
const [sessions, setSessions] = useState([])
|
||||
const [sessionsLoading, setSessionsLoading] = useState(true)
|
||||
const [railOpen, setRailOpen] = useState(true)
|
||||
const navigate = useNavigate()
|
||||
|
||||
const load = useCallback(() => {
|
||||
|
|
@ -85,22 +82,12 @@ export default function AnalysisPage() {
|
|||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
// Every session, not a recent handful: this rail is the whole list now.
|
||||
useEffect(() => {
|
||||
api.get('/quizzes/sessions')
|
||||
.then(res => setSessions(Array.isArray(res.data) ? res.data : []))
|
||||
.catch(() => setSessions([]))
|
||||
.finally(() => setSessionsLoading(false))
|
||||
}, [])
|
||||
|
||||
const startAdaptive = () => navigate(`/study/new?adaptive=1&count=${count}`)
|
||||
const startCategory = (categoryId) => navigate(`/study/new?category=${categoryId}&count=${count}`)
|
||||
|
||||
return (
|
||||
<div className={`an-layout${railOpen ? '' : ' rail-closed'}`}>
|
||||
<SessionRail sessions={sessions} open={railOpen} loading={sessionsLoading}
|
||||
onToggle={() => setRailOpen(v => !v)} />
|
||||
|
||||
<AnalysisShell>
|
||||
<div className="an-page">
|
||||
<div className="an-header">
|
||||
<h1>
|
||||
|
|
@ -206,6 +193,6 @@ export default function AnalysisPage() {
|
|||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AnalysisShell>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,24 +1,6 @@
|
|||
/* Session analysis: a rail of sessions, the figures that matter, then the table. */
|
||||
|
||||
.an-page { display: grid; grid-template-columns: 260px 1fr; gap: 22px; align-items: start; max-width: 1240px; margin: 0 auto; }
|
||||
.an-page.is-narrow { grid-template-columns: 1fr; }
|
||||
.an-page.is-narrow .an-rail { display: none; }
|
||||
|
||||
.an-rail { position: sticky; top: 76px; max-height: calc(100vh - 100px); overflow-y: auto; background: var(--card-bg); border: 1px solid var(--border); border-radius: 12px; }
|
||||
.an-rail-head { display: flex; align-items: center; justify-content: space-between; padding: 14px 14px 8px; }
|
||||
.an-rail-head h2 { margin: 0; font-size: 0.95rem; }
|
||||
.an-rail-head button { background: none; border: 1px solid var(--border); border-radius: 50%; width: 26px; height: 26px; cursor: pointer; color: var(--text-muted); }
|
||||
.an-rail ul { list-style: none; margin: 0; padding: 0 0 8px; }
|
||||
.an-rail-item { display: flex; flex-direction: column; gap: 3px; padding: 11px 14px; text-decoration: none; border-left: 3px solid transparent; }
|
||||
.an-rail-item:hover { background: var(--bg); }
|
||||
.an-rail-item.is-active { background: var(--option-sel-bg); border-left-color: var(--primary); }
|
||||
.an-rail-mode { font-size: 0.78rem; font-weight: 700; color: var(--text); }
|
||||
.an-rail-title { font-size: 0.82rem; color: var(--text-muted); overflow-wrap: anywhere; }
|
||||
.an-rail-count { font-size: 0.7rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: var(--primary); }
|
||||
.an-rail-bar { height: 3px; border-radius: 2px; background: var(--border); overflow: hidden; }
|
||||
.an-rail-bar .is-right { display: block; height: 100%; background: var(--correct-fg); }
|
||||
.an-rail-empty { padding: 12px 14px; font-size: 0.84rem; color: var(--text-muted); }
|
||||
.an-rail-show { position: sticky; top: 76px; align-self: start; background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; font-size: 0.8rem; cursor: pointer; color: var(--text-muted); }
|
||||
|
||||
.an-main { min-width: 0; }
|
||||
.an-empty { background: var(--card-bg); border: 1px solid var(--border); border-radius: 12px; padding: 32px; text-align: center; color: var(--text-muted); }
|
||||
|
|
@ -102,15 +84,14 @@
|
|||
border: 1px solid var(--wrong-bd); border-radius: 8px;
|
||||
}
|
||||
|
||||
/* A session with nothing sat yet. States the absence rather than showing a
|
||||
grid of zeroes, which reads as a score of nought. */
|
||||
.an-notyet {
|
||||
max-width: 560px; padding: 22px 24px; text-align: center;
|
||||
background: var(--card-bg); border: 1px solid var(--border); border-radius: 12px;
|
||||
/* Why the figures below are zero. Without this an untouched session reads as
|
||||
a score of nought rather than as one not yet sat. */
|
||||
.an-incomplete {
|
||||
margin: 0 0 16px; padding: 11px 14px; font-size: 0.85rem; line-height: 1.6;
|
||||
color: var(--text-muted); background: var(--bg);
|
||||
border: 1px solid var(--border); border-left: 3px solid var(--primary);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.an-notyet-lead { margin: 0 0 6px; font-size: 1.02rem; font-weight: 650; }
|
||||
.an-notyet p { margin: 0 0 10px; font-size: .88rem; color: var(--text-muted); }
|
||||
.an-notyet-note { line-height: 1.6; }
|
||||
|
||||
/* Where this session sits in its study plan. A session from a plan is still a
|
||||
session, but it is also a step in a sequence, and the next step is the
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import SessionRail from '../components/SessionRail'
|
||||
import AnalysisShell from '../components/AnalysisShell'
|
||||
import api from '../api/client'
|
||||
import './AnalysisSessionPage.css'
|
||||
|
||||
|
|
@ -72,14 +72,11 @@ export default function AnalysisSessionPage() {
|
|||
// the quiz itself, so the page can say what is missing instead of 404ing.
|
||||
const { attemptId, quizId } = useParams()
|
||||
const [data, setData] = useState(null)
|
||||
const [sessions, setSessions] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [sort, setSort] = useState('position')
|
||||
// Ten at a time: a session of forty is a table nobody reads to the end of.
|
||||
const [page, setPage] = useState(0)
|
||||
const [railOpen, setRailOpen] = useState(true)
|
||||
const [sessionsLoading, setSessionsLoading] = useState(true)
|
||||
const [confirmDelete, setConfirmDelete] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
|
|
@ -95,21 +92,18 @@ export default function AnalysisSessionPage() {
|
|||
}
|
||||
}
|
||||
|
||||
// Addressed by attempt, or — for a session nobody has sat — by quiz, which
|
||||
// answers with the same shape at zero. The page does not branch on which.
|
||||
const load = useCallback(() => {
|
||||
if (!attemptId) { setLoading(false); return }
|
||||
setLoading(true)
|
||||
api.get(`/attempts/${attemptId}/analysis`)
|
||||
const url = attemptId ? `/attempts/${attemptId}/analysis` : `/attempts/quiz/${quizId}/analysis`
|
||||
api.get(url)
|
||||
.then(res => setData(res.data))
|
||||
.catch(() => setError('Could not load this session'))
|
||||
.finally(() => setLoading(false))
|
||||
}, [attemptId])
|
||||
}, [attemptId, quizId])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
useEffect(() => {
|
||||
api.get('/quizzes/sessions').then(res => setSessions(res.data || []))
|
||||
.catch(() => setSessions([]))
|
||||
.finally(() => setSessionsLoading(false))
|
||||
}, [])
|
||||
|
||||
const rows = useMemo(
|
||||
() => (data ? [...data.questions].sort(SORTS[sort]) : []), [data, sort])
|
||||
|
|
@ -118,73 +112,41 @@ export default function AnalysisSessionPage() {
|
|||
const shown = rows.slice(page * PER_PAGE, page * PER_PAGE + PER_PAGE)
|
||||
useEffect(() => { setPage(0) }, [sort])
|
||||
|
||||
if (loading) return <div className="loading"><div className="spinner" /></div>
|
||||
|
||||
// Addressed by quiz rather than attempt: nothing has been sat, so there is
|
||||
// nothing to analyse. Opening a session used to launch it — clicking a name
|
||||
// in a list dropped you into a 240-question exam with the clock running.
|
||||
// This says what the session is and lets you decide.
|
||||
if (!data && quizId) {
|
||||
const row = sessions.find(item => String(item.quiz_id) === String(quizId))
|
||||
return (
|
||||
<div className={`an-page${railOpen ? '' : ' is-narrow'}`}>
|
||||
<SessionRail sessions={sessions} open={railOpen} loading={sessionsLoading}
|
||||
onToggle={() => setRailOpen(v => !v)} />
|
||||
{!railOpen && (
|
||||
<button type="button" className="an-rail-show" onClick={() => setRailOpen(true)}>
|
||||
› Sessions
|
||||
</button>
|
||||
)}
|
||||
<main className="an-main">
|
||||
<div className="an-head">
|
||||
<h1>Your performance for <span>{row?.title || 'this session'}</span></h1>
|
||||
</div>
|
||||
<div className="an-notyet">
|
||||
<p className="an-notyet-lead">You have not answered any of this yet.</p>
|
||||
<p>
|
||||
{row ? <>{row.questions_per_attempt || row.questions_count} questions
|
||||
{' · '}{row.mode === 'learning' ? 'Study mode' : 'Exam mode'}</> : null}
|
||||
</p>
|
||||
<p className="an-notyet-note">
|
||||
Sit it through to the end and this page fills in: how you scored,
|
||||
where the time went, which topics to go back to and what to read.
|
||||
</p>
|
||||
<Link className="btn btn-primary" to={`/study/${quizId}`}>Start this session</Link>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
if (loading) {
|
||||
return <AnalysisShell><div className="loading"><div className="spinner" /></div></AnalysisShell>
|
||||
}
|
||||
if (!data) {
|
||||
return <AnalysisShell><div className="an-empty">{error || 'Session not found.'}</div></AnalysisShell>
|
||||
}
|
||||
|
||||
if (!data) return <div className="an-empty">{error || 'Session not found.'}</div>
|
||||
|
||||
const correct = data.score
|
||||
const incorrect = data.answered - data.score
|
||||
const skipped = data.total - data.answered
|
||||
|
||||
return (
|
||||
<div className={`an-page${railOpen ? '' : ' is-narrow'}`}>
|
||||
<SessionRail sessions={sessions} open={railOpen} loading={sessionsLoading}
|
||||
onToggle={() => setRailOpen(v => !v)} />
|
||||
|
||||
{!railOpen && (
|
||||
<button type="button" className="an-rail-show" onClick={() => setRailOpen(true)}>
|
||||
› Sessions
|
||||
</button>
|
||||
)}
|
||||
|
||||
<main className="an-main">
|
||||
<AnalysisShell>
|
||||
<div className="an-main">
|
||||
<div className="an-head">
|
||||
<h1>Your performance for <span>{data.title}</span></h1>
|
||||
<div className="an-head-actions">
|
||||
<Link className="btn btn-secondary btn-sm" to={`/results/${attemptId}`}>Review answers</Link>
|
||||
{data.quiz_id && (
|
||||
<Link className="btn btn-secondary btn-sm" to={`/study/${data.quiz_id}?restart=1`}>Retake</Link>
|
||||
{/* Nothing sat yet: the only thing to offer is the sitting. */}
|
||||
{data.not_started ? (
|
||||
<Link className="btn btn-primary btn-sm" to={`/study/${data.quiz_id}`}>Start this session</Link>
|
||||
) : (
|
||||
<>
|
||||
<Link className="btn btn-secondary btn-sm" to={`/results/${attemptId}`}>Review answers</Link>
|
||||
{!data.completed_at && (
|
||||
<Link className="btn btn-primary btn-sm" to={`/study/${data.quiz_id}`}>Resume</Link>
|
||||
)}
|
||||
{data.completed_at && data.quiz_id && (
|
||||
<Link className="btn btn-secondary btn-sm" to={`/study/${data.quiz_id}?restart=1`}>Retake</Link>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* Deleting is destructive and irreversible, so it asks first —
|
||||
inline, because a browser confirm() is not something this
|
||||
codebase uses. */}
|
||||
{confirmDelete ? (
|
||||
{!data.not_started && (confirmDelete ? (
|
||||
<>
|
||||
<button type="button" className="btn btn-sm an-danger" disabled={deleting}
|
||||
onClick={deleteSession}>{deleting ? 'Deleting…' : 'Delete for good'}</button>
|
||||
|
|
@ -194,7 +156,7 @@ export default function AnalysisSessionPage() {
|
|||
) : (
|
||||
<button type="button" className="btn btn-secondary btn-sm an-danger"
|
||||
onClick={() => setConfirmDelete(true)}>Delete session</button>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{data.plan && (
|
||||
|
|
@ -218,6 +180,19 @@ export default function AnalysisSessionPage() {
|
|||
</p>
|
||||
)}
|
||||
|
||||
{/* The figures below are all zero and every row reads "skipped", which
|
||||
is the true picture. Saying why keeps that from looking like a
|
||||
score of nought. */}
|
||||
{(data.not_started || data.answered < data.total) && (
|
||||
<p className="an-incomplete" role="status">
|
||||
{data.not_started
|
||||
? 'Nothing answered yet.'
|
||||
: `${data.total - data.answered} of ${data.total} questions still unanswered.`}
|
||||
{' '}Sit it through to the end and this page fills in — where the time went,
|
||||
which topics to go back to, and what to read next.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="an-figures">
|
||||
{[
|
||||
['✓', `${data.percent}%`, 'correct'],
|
||||
|
|
@ -320,7 +295,7 @@ export default function AnalysisSessionPage() {
|
|||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</AnalysisShell>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
121
frontend/src/pages/AnalysisSessionPage.test.jsx
Normal file
121
frontend/src/pages/AnalysisSessionPage.test.jsx
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { render, screen, within } from '@testing-library/react'
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom'
|
||||
import AnalysisSessionPage from './AnalysisSessionPage'
|
||||
import api from '../api/client'
|
||||
|
||||
vi.mock('../api/client', () => ({ default: { get: vi.fn(), post: vi.fn(), delete: vi.fn() } }))
|
||||
|
||||
const SESSIONS = [
|
||||
{ quiz_id: 3, title: 'My Custom Test', mode: 'learning', state: 'not_started',
|
||||
answered: 0, total: 20, last_attempt_id: null },
|
||||
]
|
||||
|
||||
const question = (position, status) => ({
|
||||
position, question_id: position, excerpt: `Stem ${position}`, status,
|
||||
difficulty: 'medium', category: 'Neonatology', seconds_spent: null, peer_percent: null,
|
||||
})
|
||||
|
||||
const NOT_STARTED = {
|
||||
attempt_id: null, quiz_id: 3, title: 'My Custom Test', mode: 'learning',
|
||||
completed_at: null, total: 20, answered: 0, score: 0, percent: 0,
|
||||
seconds_total: null, seconds_per_question: null,
|
||||
questions: Array.from({ length: 20 }, (_, i) => question(i + 1, 'skipped')),
|
||||
recommendations: [], plan: null, not_started: true,
|
||||
}
|
||||
|
||||
const SAT = {
|
||||
attempt_id: 91, quiz_id: 3, title: 'My Custom Test', mode: 'learning',
|
||||
completed_at: '2026-09-11T10:00:00', total: 2, answered: 2, score: 1, percent: 50,
|
||||
seconds_total: 180, seconds_per_question: 90,
|
||||
questions: [question(1, 'correct'), question(2, 'incorrect')],
|
||||
recommendations: [], plan: null, not_started: false,
|
||||
}
|
||||
|
||||
const mock = (payload) => api.get.mockImplementation(url => {
|
||||
if (url === '/quizzes/sessions') return Promise.resolve({ data: SESSIONS })
|
||||
if (url.includes('/analysis')) return Promise.resolve({ data: payload })
|
||||
return Promise.resolve({ data: [] })
|
||||
})
|
||||
|
||||
const mountQuiz = () => render(
|
||||
<MemoryRouter initialEntries={['/sessions/q/3']}>
|
||||
<Routes><Route path="/sessions/q/:quizId" element={<AnalysisSessionPage />} /></Routes>
|
||||
</MemoryRouter>)
|
||||
|
||||
const mountAttempt = () => render(
|
||||
<MemoryRouter initialEntries={['/sessions/91']}>
|
||||
<Routes><Route path="/sessions/:attemptId" element={<AnalysisSessionPage />} /></Routes>
|
||||
</MemoryRouter>)
|
||||
|
||||
describe('a session nobody has sat', () => {
|
||||
beforeEach(() => { vi.clearAllMocks(); mock(NOT_STARTED) })
|
||||
|
||||
it('is the same page at zero, not a different kind of page', async () => {
|
||||
mountQuiz()
|
||||
// The real figures, all zero. Scoped to the figure strip: the title and
|
||||
// the percentage also appear in the rail and the donut.
|
||||
const figures = (await screen.findByText('0/20')).closest('.an-figures')
|
||||
expect(within(figures).getByText('0%')).toBeInTheDocument()
|
||||
expect(within(figures).getByText('0/20')).toBeInTheDocument()
|
||||
// And the real table, every row skipped.
|
||||
// Ten rows a page, every one of them skipped.
|
||||
expect(screen.getAllByText('skipped')).toHaveLength(10)
|
||||
})
|
||||
|
||||
it('says why the figures are zero rather than letting it read as a score', async () => {
|
||||
mountQuiz()
|
||||
expect(await screen.findByText(/Nothing answered yet/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Sit it through to the end/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('offers the sitting, and nothing that makes no sense yet', async () => {
|
||||
mountQuiz()
|
||||
expect(await screen.findByRole('link', { name: 'Start this session' }))
|
||||
.toHaveAttribute('href', '/study/3')
|
||||
expect(screen.queryByRole('link', { name: 'Review answers' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Delete session' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('asks the quiz endpoint, since there is no attempt to ask about', async () => {
|
||||
mountQuiz()
|
||||
await screen.findByText(/Nothing answered yet/)
|
||||
expect(api.get).toHaveBeenCalledWith('/attempts/quiz/3/analysis')
|
||||
})
|
||||
})
|
||||
|
||||
describe('a session that has been sat', () => {
|
||||
beforeEach(() => { vi.clearAllMocks(); mock(SAT) })
|
||||
|
||||
it('shows its score and the way back into the answers', async () => {
|
||||
mountAttempt()
|
||||
const figures = (await screen.findByText("2/2")).closest('.an-figures')
|
||||
expect(within(figures).getByText('50%')).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: 'Review answers' })).toHaveAttribute('href', '/results/91')
|
||||
expect(screen.getByRole('link', { name: 'Retake' })).toHaveAttribute('href', '/study/3?restart=1')
|
||||
expect(screen.getByRole('button', { name: 'Delete session' })).toBeInTheDocument()
|
||||
// Nothing outstanding, so no "still unanswered" note.
|
||||
expect(screen.queryByText(/still unanswered/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the session rail beside it', async () => {
|
||||
mountAttempt()
|
||||
const rail = await screen.findByRole('complementary', { name: 'Sessions' })
|
||||
expect(within(rail).getByRole('link', { name: /Your overall analysis/ }))
|
||||
.toHaveAttribute('href', '/sessions')
|
||||
})
|
||||
})
|
||||
|
||||
describe('a session left part way', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mock({ ...SAT, completed_at: null, answered: 1, score: 1, percent: 50, total: 2 })
|
||||
})
|
||||
|
||||
it('counts what is outstanding and offers to resume', async () => {
|
||||
mountAttempt()
|
||||
expect(await screen.findByText(/1 of 2 questions still unanswered/)).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: 'Resume' })).toHaveAttribute('href', '/study/3')
|
||||
expect(screen.queryByRole('link', { name: 'Retake' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue