From 2b5262d255a03690ac4657b3a3dce268292b048f Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 12 Sep 2026 06:50:10 +0200 Subject: [PATCH] feat: the objective is enforced, folders are made where you are, and the landing page describes this product MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An objective is now required to build a session, not only asked for in the interface — the interface asks, and this is the same rule where it cannot be walked past. Only where there is something to choose: a deployment with no exams, and the first administrator of a fresh one, must still be able to build a session. A rule that locks an empty site is not a rule, it is a fault. Saving a question into a folder is one box that searches what you have and offers to make what you do not. It used to say "make one in the question bank" and leave you to go and do it, which means leaving the question you were reading and coming back to find your place. A name that already exists exactly is not offered twice; a partial match offers both, because wanting a narrower folder called "cardio" is not the same as wanting the one called "Cardiology misses". The landing page is rebuilt. Its copy described a product from months ago — "upload a PDF, AI extracts questions", which is one feature of many now — and it was 568 lines of inline style objects, which cannot express a hover, a media query or a keyframe. The figures come from /api/public/stats and count up; a failed fetch renders the section without them rather than showing noughts, which would be a lie about an empty bank. Motion is CSS and SVG, and prefers-reduced-motion turns all of it off — including forcing the scroll-revealed elements visible, since a hidden element with its animation removed is how respecting that setting turns into a blank page. Two smaller ones from the screenshots: the collections shelf is boxed rather than scrolling past everything else on the page, and its rows no longer carry the entire stem — lab tables and all — in a native tooltip that covered half the screen and could not be dismissed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN --- backend/app/services/quiz_builder.py | 24 + backend/tests/test_quiz_builder.py | 40 ++ docs/TODO.md | 52 +- frontend/src/pages/CollectionsPage.css | 8 +- frontend/src/pages/CollectionsPage.jsx | 8 +- frontend/src/pages/LandingPage.css | 347 ++++++++++++ frontend/src/pages/LandingPage.jsx | 686 ++++++++++++++---------- frontend/src/pages/LandingPage.test.jsx | 149 +++++ frontend/src/pages/QuizPage.jsx | 103 +++- frontend/src/pages/QuizPage.test.jsx | 55 ++ frontend/src/pages/QuizPlayer.css | 22 + 11 files changed, 1173 insertions(+), 321 deletions(-) create mode 100644 frontend/src/pages/LandingPage.css create mode 100644 frontend/src/pages/LandingPage.test.jsx diff --git a/backend/app/services/quiz_builder.py b/backend/app/services/quiz_builder.py index 68566d5..83102e3 100644 --- a/backend/app/services/quiz_builder.py +++ b/backend/app/services/quiz_builder.py @@ -356,7 +356,31 @@ def adaptive_select(db, user, count, category_ids, state, difficulty): return selected +def require_objective(db, user) -> None: + """Refuse to build a session for somebody who has not said what they study. + + The objective decides which questions exist, how relevance is weighted and + what readiness is measured against. No objective quietly means the whole + bank — a reasonable default, and a poor thing to arrive at by accident, + which is what was happening: the interface asks now, and this is the same + rule where it cannot be walked past. + + Only where there is something to choose. A deployment with no exams + configured, and the first administrator of a fresh one, must still be able + to build a session; a rule that locks an empty site is not a rule, it is a + fault. + """ + if getattr(user, "active_exam_id", None): + return + from app.models.exam import Exam + + if not db.query(Exam.id).filter(Exam.is_active == 1).first(): + return + raise HTTPException(400, "Choose what you are studying for before building a session") + + def generate_test(db, user, data): + require_objective(db, user) if data.algorithm == "blueprint": return _blueprint_test(db, user, data) if data.algorithm == "adaptive": diff --git a/backend/tests/test_quiz_builder.py b/backend/tests/test_quiz_builder.py index ad903f2..60d52c1 100644 --- a/backend/tests/test_quiz_builder.py +++ b/backend/tests/test_quiz_builder.py @@ -453,3 +453,43 @@ class ExamClockTests(unittest.TestCase): time_limit_minutes = 15 self.assertEqual(exam_minutes(Ask(), 40), 15) + + +class ObjectiveRequiredTests(unittest.TestCase): + """A session cannot be built by somebody who has not said what they study. + + The objective decides which questions exist, how relevance is weighted and + what readiness measures against. The interface asks; this is the same rule + where it cannot be walked past. + """ + + def setUp(self): + self.bank = BuilderTests() + self.bank.setUp() + self.client = self.bank.client + self.db = self.bank.db + + def tearDown(self): + self.bank.tearDown() + + def offer(self): + from app.models.exam import Exam + self.db.add(Exam(id=1, slug='pediatrics-boards', name='Pediatrics Boards', is_active=1)) + self.db.commit() + + def test_with_an_objective_on_offer_and_none_chosen_it_refuses(self): + self.offer() + response = self.bank.generate(is_shared=True, category_ids=[1]) + self.assertEqual(response.status_code, 400, response.text) + self.assertIn('studying for', response.json()['detail']) + + def test_choosing_one_lets_it_through(self): + self.offer() + self.bank.owner.active_exam_id = 1 + self.db.commit() + self.assertEqual(self.bank.generate(is_shared=True, category_ids=[1]).status_code, 200) + + def test_a_site_with_no_objectives_is_not_locked(self): + # A rule that locks an empty deployment is not a rule, it is a fault: + # the first administrator has nothing to choose from yet. + self.assertEqual(self.bank.generate(is_shared=True, category_ids=[1]).status_code, 200) diff --git a/docs/TODO.md b/docs/TODO.md index ff3c8be..86ffec4 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -332,12 +332,12 @@ Analysis**, which has three tabs. ### Recommendations -- [ ] **Your knowledge profile** — topics ranked by score, lowest marked +- [x] **Your knowledge profile** — topics ranked by score, lowest marked **FOCUS AREA**, under three tabs: **Articles, Systems, Disciplines**. Columns: Topic, score, **Relevance**, Status, Action. A row expands to questions completed with a bar, answered-correctly with a - correct/hints/incorrect bar, and a Start Qbank button for that topic. -- [ ] **Relevance is the ABP content specification weight.** This is the part + correct/hints/incorrect bar, and a Start Qbank button for that topic. — done 2026-09-12 — paginated, with the three axes and expandable rows. +- [x] **Relevance is the ABP content specification weight.** This is the part we can do properly and AMBOSS cannot explain: a topic's relevance is the share of the real paper its domain accounts for, which `exam_blueprints.weight` already holds. Cardiology at 5% and @@ -345,7 +345,7 @@ Analysis**, which has three tabs. say so from the board's own numbers rather than from a guess. ### Session Analysis - + — done — exam_blueprints.weight drives the Relevance column and now adaptive selection too. - [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 @@ -368,13 +368,13 @@ Analysis**, which has three tabs. The whole pipeline from a PDF to a question in the bank lives on one page, and nothing reaches the bank until an administrator has read it. -- [ ] **Move the PDFs into Tools.** Upload, the document list and extraction +- [x] **Move the PDFs into Tools.** Upload, the document list and extraction all move off their own pages and onto the Tools page, because they are - one job and were three places. -- [ ] **Nextcloud is the administrator's alone.** Nobody else connects an + one job and were three places. — done — upload, the document list and extraction all live on /tools. +- [x] **Nextcloud is the administrator's alone.** Nobody else connects an account; it is an import path for whoever loads the corpus, not a - per-learner integration. It moves out of everyone's Settings. -- [ ] **Review and promote, on the same page.** A run lands as a batch of + per-learner integration. It moves out of everyone's Settings. — done. +- [x] **Review and promote, on the same page.** A run lands as a batch of drafts — already built, `draft_batches` / `draft_questions`, with their own sequence so nothing takes a question id early. What is missing is the screen: read them, fix them, reject the rubbish, then select the @@ -382,7 +382,7 @@ and nothing reaches the bank until an administrator has read it. moment a `Question` is created. ## Collections, as shown 2026-09-11 (evening) - + — done — a batch is read and accepted in place, and the question id is taken only on promotion. - [x] **A collections page** — done 2026-09-12. `/collections`, in the section bar beside Qbank. Card and Table views (the choice is remembered), sort by last used / created / name / size with a direction control, a count @@ -513,25 +513,25 @@ needs are `conversations` and `messages`. The reference is UWorld-style for the exam player and AMBOSS for analysis. ### Exam player chrome -- [ ] **Top bar**: `Item: n of m` / `Block: 1 of 1` at the left, Previous / n of m +- [x] **Top bar** — done 2026-09-12.: `Item: n of m` / `Block: 1 of 1` at the left, Previous / n of m / Next in the middle, Lab Values · Notes · Calculator · Settings at the right. -- [ ] **Bottom bar**: `Block Time Remaining: 00:01:29` at the left, Pause and +- [x] **Bottom bar** — done 2026-09-12.: `Block Time Remaining: 00:01:29` at the left, Pause and Lock in the middle, End Block at the right. -- [ ] **One button, not two.** A question with no answer offers Skip *and* +- [x] **One button, not two.** — done 2026-09-12. A question with no answer offers Skip *and* Next today. It is one boxed control: one button. -- [ ] **Proceed to Next Item on the last question ends the block** rather than +- [x] **Proceed to Next Item on the last question ends the block** — done 2026-09-12 — End Block replaced three controls under two names. rather than doing nothing. -- [ ] **Cross out an option** from a per-option control beside it (the `ab` +- [x] **Cross out an option** — already there — the `ab` control per option. from a per-option control beside it (the `ab` strike icon), not only from a menu. -- [ ] **Pause says "Exam Paused" and nothing else** — a title and a Return to +- [x] **Pause says "Exam Paused" and nothing else** — done 2026-09-12. — a title and a Return to exam button. No warning about real exams; that is AMBOSS's disclaimer, not ours. -- [ ] **End Session is a plain confirmation** — "Are you sure you want to end +- [x] **End Session is a plain confirmation** — done 2026-09-12. — "Are you sure you want to end this session?", End Session / Cancel. -- [ ] **Right-click removes a highlight**, and the yellow itself needs fixing. -- [ ] **The rail shows numbers while sitting an exam and shortened stems in - review.** It is numbers in both today. +- [x] **Right-click removes a highlight** — done 2026-09-12 — the yellow is a solid band now, not a gradient stripe., and the yellow itself needs fixing. +- [x] **The rail shows numbers while sitting an exam and shortened stems in + review.** — done 2026-09-12. It is numbers in both today. ### Player review mode — specified 12 Sep from a finished exam Reviewing a block that has ended looks like study mode, **whether or not @@ -542,20 +542,20 @@ other option struck red with the peer percentage beside it, KEY INFO / ATTENDING TIP / LABS, SHOW ALL EXPLANATIONS and HIDE STATS along the foot, and PREVIOUS / SKIP to move between them. -- [ ] **"Finished" means the attempt is closed, not that every question was +- [x] **"Finished" means the attempt is closed — done 2026-09-12., not that every question was answered.** The flag in QuizPage is `answeredCount >= totalCount`, which is wrong for exactly this case: a block that timed out with nothing answered is over, and it still shows numbers and hides explanations. -- [ ] **The player opens a completed attempt in review** rather than sending +- [x] **The player opens a completed attempt in review** — done 2026-09-12. rather than sending people to `/results/:id`, which makes that page redundant. ### Analysis -- [ ] **Session Analysis is the third tab**, inside the Analysis page with the +- [x] **Session Analysis is the third tab** — done 2026-09-12., inside the Analysis page with the session rail beside it — not a link away. Four figures, the donut, and Study recommendations under Articles / Disciplines / Systems. -- [ ] **An unfinished session shows Resume**, with the figures reading `--` +- [x] **An unfinished session shows Resume** — done 2026-09-12., with the figures reading `--` rather than a blank card. -- [ ] **Knowledge profile paginates** (`1 – 10 of 20`) and each row expands to +- [x] **Knowledge profile paginates** — done 2026-09-12. (`1 – 10 of 20`) and each row expands to two bars — questions completed, answered correctly split correct / correct-using-hints / incorrect — with Read article and Start Qbank beside them. @@ -563,7 +563,7 @@ PREVIOUS / SKIP to move between them. ### Elsewhere - [x] **Cap replaces Turnstile**, everywhere Turnstile was wired — one shared `Captcha` component, one `captcha` service, keys blank until issued. -- [ ] **The session rail scrolls** once it holds more than a screenful. +- [x] **The session rail scrolls** — already worked — measured in headless Chrome at three viewports rather than assumed. once it holds more than a screenful. ## Done this session diff --git a/frontend/src/pages/CollectionsPage.css b/frontend/src/pages/CollectionsPage.css index 36a1050..281a1ef 100644 --- a/frontend/src/pages/CollectionsPage.css +++ b/frontend/src/pages/CollectionsPage.css @@ -93,7 +93,13 @@ /* ── What is on one shelf ───────────────────────────────────────────── Opened in place: there is no browsable question list to send anyone to, and a link that goes nowhere is worse than no link. */ -.col-shelf { list-style: none; margin: 12px 0 0; padding: 10px 0 0; border-top: 1px solid var(--border); } +.col-shelf { + list-style: none; margin: 12px 0 0; padding: 10px 0 0; + border-top: 1px solid var(--border); + /* Boxed. Sixty-seven saved questions inside a card is a page that scrolls + past everything else on it before it ends. */ + max-height: 320px; overflow-y: auto; overscroll-behavior: contain; +} .col-shelf li { display: flex; align-items: flex-start; gap: 8px; padding: 5px 0; font-size: 0.82rem; } .col-shelf-stem { flex: 1; min-width: 0; color: var(--text-muted); line-height: 1.5; diff --git a/frontend/src/pages/CollectionsPage.jsx b/frontend/src/pages/CollectionsPage.jsx index c2d1a64..ba76eac 100644 --- a/frontend/src/pages/CollectionsPage.jsx +++ b/frontend/src/pages/CollectionsPage.jsx @@ -26,9 +26,11 @@ function Shelf({ rows, onDrop }) {