From 8f73e0f75b2995684fa8e1c6c06791981c348dc6 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 11 Sep 2026 05:08:56 +0200 Subject: [PATCH] fix: one analysis layout, and an unsat session is the same page at zero MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN --- backend/app/routers/attempts.py | 63 ++++ backend/scripts/link_articles.py | 275 ++++++++++++++---- backend/tests/test_session_lifecycle.py | 45 +++ frontend/src/components/AnalysisShell.css | 117 ++++++++ frontend/src/components/AnalysisShell.jsx | 46 +++ frontend/src/components/SessionRail.css | 36 --- frontend/src/components/SessionRail.jsx | 22 +- frontend/src/pages/AnalysisPage.css | 38 +-- frontend/src/pages/AnalysisPage.jsx | 19 +- frontend/src/pages/AnalysisSessionPage.css | 33 +-- frontend/src/pages/AnalysisSessionPage.jsx | 111 +++---- .../src/pages/AnalysisSessionPage.test.jsx | 121 ++++++++ 12 files changed, 671 insertions(+), 255 deletions(-) create mode 100644 frontend/src/components/AnalysisShell.css create mode 100644 frontend/src/components/AnalysisShell.jsx delete mode 100644 frontend/src/components/SessionRail.css create mode 100644 frontend/src/pages/AnalysisSessionPage.test.jsx diff --git a/backend/app/routers/attempts.py b/backend/app/routers/attempts.py index 59d85de..0684ab3 100644 --- a/backend/app/routers/attempts.py +++ b/backend/app/routers/attempts.py @@ -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, } diff --git a/backend/scripts/link_articles.py b/backend/scripts/link_articles.py index 19b25fa..07bf8b3 100644 --- a/backend/scripts/link_articles.py +++ b/backend/scripts/link_articles.py @@ -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"(? 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"(? 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}") diff --git a/backend/tests/test_session_lifecycle.py b/backend/tests/test_session_lifecycle.py index c5340e2..8ec08a1 100644 --- a/backend/tests/test_session_lifecycle.py +++ b/backend/tests/test_session_lifecycle.py @@ -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) diff --git a/frontend/src/components/AnalysisShell.css b/frontend/src/components/AnalysisShell.css new file mode 100644 index 0000000..066b749 --- /dev/null +++ b/frontend/src/components/AnalysisShell.css @@ -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; } +} diff --git a/frontend/src/components/AnalysisShell.jsx b/frontend/src/components/AnalysisShell.jsx new file mode 100644 index 0000000..0e3db46 --- /dev/null +++ b/frontend/src/components/AnalysisShell.jsx @@ -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 ( +
+ setOpen(v => !v)} /> + {!open && ( + + )} +
{children}
+
+ ) +} diff --git a/frontend/src/components/SessionRail.css b/frontend/src/components/SessionRail.css deleted file mode 100644 index 6a16ff1..0000000 --- a/frontend/src/components/SessionRail.css +++ /dev/null @@ -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; } -} diff --git a/frontend/src/components/SessionRail.jsx b/frontend/src/components/SessionRail.jsx index 15de512..51ef2ca 100644 --- a/frontend/src/components/SessionRail.jsx +++ b/frontend/src/components/SessionRail.jsx @@ -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 ( -