Commit graph

293 commits

Author SHA1 Message Date
Daniel
789cd1cc81 feat: right after a tip is its own slice
Opening a tip before answering is a nudge. The answer that follows is
still right — it is counted as right, and the percentage is not docked —
but it is not the same as right, so it keeps its own arc on the donut and
its own line in the legend: "3 correct after a tip".

attempt_answers.used_hint records it. The player reports which questions
had a tip opened before the answer went in; a tip read afterwards is
revision and does not count, which is the difference two of the tests
turn on. Both endings agree about it — an explicit submit carries the
list, and an exam that runs out takes it from the saved progress, so a
tab closing cannot launder a score.

Found while wiring this: RichText declared its component overrides inline
in the render, so every one was a fresh component type and React
remounted the whole rendered tree on each render. An open tip closed
itself every time the exam clock ticked. The map is memoised now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 01:51:03 +02:00
Daniel
f048b1f4b6 feat: tips in the prose, underlined where they are needed
`{{stridor|Inspiratory stridor is extrathoracic until proven otherwise}}`
in any stem, option, explanation or article renders the phrase with a
dotted underline; clicking it opens the sentence where the word is.
Nothing is revealed until asked for, and Esc or a second click closes it.

The question already had an Attending tip — one panel, for the whole
question, reached from the toolbar. This is the other half: the point
that belongs to one word, said next to that word.

Done as a remark plugin rather than a search-and-replace on the source,
because highlights and the read-aloud cursor are stored as offsets into
the raw text; rewriting the string before it is parsed would move every
offset after the first tip. The split pieces carry line and column as
well as offset — a point with only one of the three is discarded
downstream, which cost the first version every highlight past the tip.
There is a test for exactly that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 01:44:29 +02:00
Daniel
2c6b821f6f style: lighter menu bar
The same slate, two steps up (#0f172a → #1e293b), and the warm theme's
bar with it. Near-black across the top read as a band sitting on the page
rather than part of it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 01:39:53 +02:00
Daniel
8c28cc4e9b feat: all attempts vs latest attempt, with the donut shared
A question got wrong in March and right in September is 50% by one count
and 100% by another, and both are true. The Performance tab now says
which it is answering: All attempts is every answer ever given — how much
work has been done — and Latest attempt keeps only the most recent answer
to each question — what is known now.

GET /study-tools/answer-split returns both splits plus the session and
unique-question counts, under the same exclusions as everything else that
measures: no repetitions, no course quizzes, no expired attempts. A blank
is its own slice, never folded into incorrect.

The ring itself moves out of AnalysisSessionPage into components/Donut so
the session view and the lifetime view cannot drift apart. Its legend
gains .is-answered, which the session page had been asking for without
anything defining it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 01:39:17 +02:00
Daniel
4e272e6ef0 feat: completion over a chosen time range, and a way back out of Tools
"How am I doing" and "how was I doing last month" are different questions,
and a single lifetime figure cannot answer both. Analysis now carries a
Completion panel on the Performance tab: questions answered against the
bank, how many were right, time per question, total time — over 7 days,
30 days, 3 months, or everything.

GET /study-tools/completion?days=N does the counting. It leaves out what
would not be a measurement: repetitions (you already know that answer),
course quizzes (they belong to their course), and expired attempts. A
question left blank is not a wrong answer, so the percentage is out of
what was answered, not out of what was set. Nothing answered reports
nothing rather than 0%.

The Tools workbench has no menu of its own by design, which left no way
back; it now opens onto Settings where it was reached from.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 01:35:14 +02:00
Daniel
06433195bb fix: a page you cannot open says so; a section row fits its card
Hitting an educators-only page redirected to the dashboard in silence,
which leaves you looking at a page you did not ask for with no idea
whether the link was broken, you mistyped, or it simply is not yours. It
says which, and offers the dashboard and settings — the two places you
are actually likely to want from there. Not being signed in stays a
redirect, because there is nothing to explain.

The section row gave its name whatever width was left after a button
group that would not wrap, so "NBME 7 STEP 3" broke to one word a line
while Extract, Create Cards and Delete ran off the right of the card. On
a narrow screen the name takes the row and the buttons take the next
one. The page had a stylesheet that nothing imported, which is why it
had no layout rules of its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 01:21:20 +02:00
Daniel
a704542a14 feat: the session drawer on a phone; and extraction says what actually failed
The phone had a dot grid dropped under the top bar — a different thing
in a different place doing the rail's job worse. It is a drawer holding
the same rail the desktop has, with the site's own menu on the other
tab, because the alternative is a second hamburger elsewhere for the
same purpose. The dot grid and its styles are gone.

And the extraction pipeline was run end to end against a three-question
PDF rather than reasoned about. It works: three questions, stems,
options, correct answers and explanations, landing in a draft batch and
not in the bank. But the run found a real bug on the way.

A document's text is read from the search index, not from the file. When
that index is missing — never processed, or lost to a restart — every
page is skipped and the job fails with "the AI could not find questions
with correct answers in this page range". That is the wrong diagnosis,
and it sends people to change the model, the prompt and the page range,
none of which is the problem. The two failures are now counted apart and
named apart: no stored text says so and says to re-process; a model that
found nothing says that instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 01:16:22 +02:00
Daniel
2d828c3c03 feat: a repetition does not raise your score; no more deleting a session
Sitting the same questions again is practice, not a new measurement. You
have already seen the answers, so getting them right the second time
says nothing about whether you knew them — and it cannot be allowed to
raise a figure that means "how much of this do you know". A repeated
session is titled "(repetition)", analysed in full on its own page, and
left out of every aggregate: the overall accuracy, the per-quiz history,
the averages, and the readiness that drives recommendations.

Deleting a single session is gone — control, endpoint, tests and all. A
session is a record of work done, and removing one edits the history
every figure on the analysis is computed from, which turns a measurement
into a number somebody chose. Starting again is still offered whole,
under Settings, Your data, which takes everything rather than the parts
that flatter.

Two layout bugs behind that. The category tree kept its appearance in
QuestionBankPage.css, so it looked right on the bank and took whatever
the host page did to a label everywhere else — in the question editor
that centred the name, leaving it adrift with the count at the far
right; it owns its own stylesheet now. And the editor's grid collapsed
to `1fr` below 900px, whose automatic minimum lets one unshrinkable
child push the column past the window: the page had padding down its
left and none down its right because the right was off the screen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 01:08:42 +02:00
Daniel
b8b80877ac fix: the study objective was near-white on near-white
The section bar is a light surface inside a dark navbar, and `.navbar`
sets a near-white text colour for that dark background. Everything in
the light bar inherited it: the study objective, its dropdown, the
counts. The section links had their own colour override and looked fine,
which is why it went unnoticed for so long.

`.navbar a`, `.navbar button` and their hovers are scoped to
`.navbar-primary` — the dark bar they were written for — rather than to
everything beneath it, which was also giving the switcher a translucent
white border on a white surface. And the section bar sets its own text
colour rather than inheriting one meant for a dark ground.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 00:55:52 +02:00
Daniel
1b996b0a3d feat: relevance is the board's published share, not our bank's proportions
The knowledge profile ranked topics by how much of *our* bank sat under
each one, which is a fact about us rather than about the exam. It made
cardiology and rheumatology equally worth an evening whenever we happened
to hold the same number of each. The ABP publishes that one is 5% of the
paper and the other 2%, and exam_blueprints.weight has held that since
the blueprint landed.

A domain's weight is divided among the topics beneath it in proportion
to the material each holds, so the topics under a domain add up to its
published share. 672 of our categories now carry one. A topic the
outline does not cover keeps the bank-share figure rather than reporting
nothing — and the row says which it is, because the two numbers mean
different things and should not be read as the same one.

Session analysis is a link to the last session rather than a third tab
with nothing behind it — a session's analysis is a session, and the rail
beside this page is the list of them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 00:53:54 +02:00
Daniel
b2ddee74dc fix: sticky was disabled site-wide, and 100vh is wrong on iOS
`html, body { overflow-x: hidden }` makes both a scroll container, and a
`position: sticky` descendant then sticks to that rather than to the
viewport — which is to say it does not stick at all. Every sticky thing
in the app was affected: the session rail, the settings nav, the article
column, the study-plan rail. `overflow-x: clip` does the same job without
becoming a scroll container. Hidden stays as the fallback, so a browser
without `clip` still cannot be scrolled sideways and only loses
stickiness, which is the lesser fault.

Putting the rail away destroyed the layout. The collapsed grid was
`0 minmax(0, 1fr)` and the rail is `display: none`, so the content
became the *first* grid item and landed in the zero-width column —
wrapping one word per line beside an empty page. One column when there
is one thing in it.

100vh is the largest viewport on iOS — the one with the URL bar hidden —
so anything sized to it is taller than the screen really is and its
bottom sits behind the bar. Eleven files now use 100dvh, which tracks
the viewport as it changes.

The AI Mode composer was the one bottom-sticky bar with no safe-area
inset; its send button sat under the home indicator.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 00:46:06 +02:00
Daniel
816ab0a66c feat: rule options out, skip says skip, and the highlighter stops covering the words
Striking an option through is how anybody works a five-option question:
eliminate, then choose among what is left. The control sits outside the
option so that ruling one out is never mistaken for picking it, and it
goes once the question is marked — there is nothing left to narrow down.
Not saved: it is working-out, not an answer, and should not follow you
into another sitting of the same question.

Moving on from a question you have not answered is a decision, so the
button says which one it is rather than calling both Next.

The highlighter was a 72%-opacity band across the bottom 62% of every
line — over the text rather than behind it. Descenders vanished into it,
small type became hard to read, and a link inside a highlight lost the
underline that said it was a link. It starts below the x-height now, is
light enough to read through, keeps link underlines, and dims on a dark
ground where the same yellow glared.

Create Test goes straight into the session. You chose the topics, the
count and the mode and pressed Create Test; an overview asking whether
you meant it is a second confirmation of a decision already made twice.

And the action bar has a layout of its own below 700px. With no rules it
wrapped into whatever the flex produced — a label on its own line, two
pills, then a Refresh and an outsized Create Test. Two rows: the mode
toggle is the question, Create Test is the answer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 00:41:46 +02:00
Daniel
e311a6b5ad fix: choosing is answering; figures say only what an educator wrote
Study mode held a choice as a draft and waited for "Submit response" — a
second press to confirm something already decided, on every question.
Clicking an option marks it now, green or red, with the explanation.
Free text is the exception and keeps Enter, because typing is not
choosing.

Figures carried a generated caption: "Figure from question #3360 (from
images/doc_23/page_704_img_0.jpeg)". That describes the database, not
the picture, and showed a learner an internal file path. 341 of them are
cleared, the indexer no longer writes them, and an unlabelled figure now
says nothing rather than "Figure 1". A screen reader still gets the
label and caption when there are any, and the position when there are
not.

Suspend, Restart and Edit are gone from above the question. Three
buttons over a question nobody was looking away from to press them; Exit
is in the bar at the bottom with the session's own controls, and
restarting and editing belong to the session list and the editor.

And iOS Safari's zoom-on-focus is fixed once rather than per field.
Safari zooms the whole page in when a control smaller than 16px takes
focus and never zooms back out, leaving the layout scaled and broken. It
was being remembered at each individual field, which meant it was
forgotten at most of them — a dozen were still under 16px. One rule for
every control on a coarse pointer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 00:19:35 +02:00
Daniel
202979f7c0 feat: a workbench — PDFs in, drafts read, questions out
Uploading, extracting and reviewing were three places and the middle one
wrote straight into the bank. They are one job, so they are one page:
the documents, the batches a run produced, and the drafts themselves,
with the counts of what is still to be read said before anything else.

The review is an editor, because that is what the staging table is for.
A draft that is not ready says why on its own row rather than on a
failed attempt to accept it, and cannot be selected until it is fixed.
Fixing happens in place, with nothing in the bank. Only the chosen,
ready ones cross over, and that is the single moment a question id is
taken — an accepted draft then says which question it became.

Nextcloud is the administrator's. It was in every learner's settings as
though each of them had a cloud to connect; it is an import path for
whoever loads the corpus.

The image work is restated in the TODO now that the ped-ai design has
actually been read rather than remembered: allow-listed widths only,
EXIF rotate, resize without enlarging, WebP q82, derivatives in the same
bucket under a thumbs/ prefix, generated best-effort. Pillow rather than
sharp — sharp is Node and this backend is Python — and no Caddy caching,
because these are behind auth and ped-ai serves its own private,
no-store for that reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 00:02:02 +02:00
Daniel
fc1aaf5dca fix: a score is out of what you answered, not out of what was set
Unanswered questions were counted as wrong in every percentage the site
reports. That made leaving an exam early look like failing it, and made
the figure say more about how far you got than about how well you did —
and how far you got is already the number sitting beside it.

An unanswered question is not a wrong answer. It is not an answer.

score_percent() and answered_counts() give the rule one definition, used
by all seven places that reported a percentage: submission, attempt
history, per-quiz history, the overall average, per-quiz stats, one
attempt's detail, and the session analysis. The list endpoints count in
one query rather than one per row.

The review dialog said unanswered questions count as incorrect, which
was true and is not any more. It now says they will not be marked wrong,
and will not be marked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 23:49:24 +02:00
Daniel
6abe3cca11 fix: review or resume, and an exam clock set by the questions
The card offered Review answers and Resume session at once on a session
still in progress, which is the muddle: there is nothing to review yet
and nothing to resume once it is done. It is one or the other now, and
what decides it is whether anything is left to answer — not whether it
was an exam or a study session, which have the same two states as each
other. A study session keeps going until every question is answered and
becomes the review at that point, without waiting to be handed in.

Repeat is offered either way. The questions worth sitting again are
worth sitting again now.

"Skipped" meant gone past, and was shown for questions in a session
still running that had not been reached. Those read "not yet answered".

And a timed block is now ninety seconds a question, set from the count
rather than asked for. Choosing a limit is a decision nobody has the
information to make — the pace belongs to the exam being rehearsed, not
to a preference — and a block sat at the wrong pace teaches the wrong
pace. Forty questions is an hour. An explicit limit is still honoured.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 23:42:27 +02:00
Daniel
cc1c981b9e feat: time's up is said, not just done; and it is acknowledged before the screen changes
The clock reaching zero submitted the attempt and replaced the exam with
an analysis, with no word about why. "It submitted itself" is the one
thing a learner must not have to infer. The answers go in immediately —
that part must not wait for anybody — and the screen holds on "Time's
up" until it is acknowledged, then goes.

The last question's control says "End block" in exam mode. It opens the
same dialog it always did, which names how many are still unanswered and
that they count as incorrect, so the warning arrives before anything is
handed in rather than after.

Four tests for the time accounting the previous commit changed, covering
what it is actually for: an exam closed with eight minutes left still
has eight minutes however long ago it was started; opening and closing
spends only the time it is open for and does eventually reach zero; an
exam closed at zero is settled on the next look, for when the tab goes
before the submit lands; and one with time on it is left alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 23:34:44 +02:00
Daniel
6860750770 fix: the exam clock runs only while somebody is sitting the exam
It ran on a wall clock. An hour away from the tab spent an hour of the
exam on questions that were never shown, and every per-question figure
was a fiction — which is the number the whole analysis is built on.

Three things stop it now. The tab being hidden, which catches switching
away. An explicit pause. And, for the commonest case the other two miss
— the tab left open on the exam while the person is in another room —
an idle watch: three minutes with no mousemove, key, wheel, touch or
scroll and it asks "Still there?", with the clock already stopped by the
time the question appears. A stray pointer movement does not answer it;
somebody has to say they are there.

Three minutes, not one, and scrolling counts as activity: reading a long
vignette is minutes without a click, and interrupting genuine reading to
ask whether you are reading is worse than occasionally crediting a
minute nobody was there for.

The server was the other half. seconds_remaining computed from
started_at and total_time, so a paused client made no difference to what
the server thought was left. It reads the saved time_left now, which is
what the player decrements only while the exam is on screen, falling
back to the wall clock for progress saved before this existed.

And a five-minute warning, said once. An exam that ends without notice
is a scramble; one that nags is a distraction.

Reverts the exam-exit-submits rule from earlier in this branch, which
was built on the opposite premise and would have charged wall-clock time
and then graded an exam whose clock should simply have stopped. Leaving
suspends, in both modes, and the overview no longer promises a clock
that does not stop for a break.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 23:30:18 +02:00
Daniel
c353373231 fix: an exam that is still running is not marked
Opening the analysis of a live attempt graded it whatever the mode. In
an exam that is a way to answer, look at whether it was right, and go
back and change it — the exam defeated rather than analysed. It reports
progress now: how many are answered, how long it is taking, and each row
as answered or not. No score, no percentage, and the donut counts how
far through it is instead of how much of it is right.

Study mode still grades live, because study mode marks each answer as it
is given; there is nothing here it has not already said.

Recommendations are withheld too, which is stricter than AMBOSS — they
show a dash for correct and then list the topics to go back to, which
says which questions were wrong by another route. A recommendation is a
verdict.

The withholding stops the moment the exam is over, submitted or expired:
settle_if_expired grades through the same function a manual submit does
and sets completed_at, and everything opens from there.

Tested on both sides, because this is an integrity rule and would come
back quietly the next time the live-analysis path was touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 23:17:43 +02:00
Daniel
28cef1e75f fix: one label for picking a session back up
Start for an unsat session and Resume for a started one is a distinction
the learner does not have to make. The session exists the moment it is
created, so picking it up is resuming it whether or not a question has
been answered yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 23:10:28 +02:00
Daniel
4fc149b8cc fix: exam mode's rail is numbers, as a real paper's is
Study mode reveals each stem in the rail once you have reached it, which
is right there — it is how you find the one you want to go back to. Exam
mode inherited it, and it should not have: a paper's question-status
rail says which items are answered and which are marked, and no more.
Reading back the stems of what you have sat, or ahead to what is coming,
is not something the exam being rehearsed would allow.

So in exam mode no row shows text, including the one you are on, and the
numbers are squared off rather than circled to match. Covered by a test,
because this is an integrity rule rather than styling and would come
back the next time the two modes shared a component.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 23:09:32 +02:00
Daniel
f9acdf42fa feat: click a question and land on it; repeat only the ones worth repeating
The analytics table linked to /results/{attempt}?q=3 and the page ignored
the q entirely, so clicking the ninth row put you at the top of the
session to page through and find it again. Both the review and the
player honour it now. A session still running opens in the player at
that question, ready to be answered; a finished one opens its review
there.

The question column is pinned while the measures scroll past it. Five
columns do not fit a phone and barely fit a laptop, and the one you need
in order to know which row you are reading is the first — so it stays,
with the stem cut to a line and the whole of it on the link's title.

Repeat session is a dialog rather than a restart. Sitting all of it
again is rarely what anyone wants: the questions worth doing again are
the ones you got wrong and the ones you never reached, and mixing in
forty you already know turns twenty useful minutes into an hour of
mostly not. It asks which outcomes and how many, counts what is
available for each, and builds a session from exactly those — shuffled,
so repeating twice is not the same order twice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 23:07:45 +02:00
Daniel
27a1679774 fix: leaving and resuming take one press; session actions sit with the result
Exit asked "Suspend quiz?" and offered Stay. Nobody presses Exit by
accident, nothing is lost — the answers are saved and the clock pauses,
which the dialog was explaining rather than deciding — and a
confirmation for a reversible act is a step, not a safeguard. One press
now, and if the save fails it stays put and says so, because leaving
then would lose the answers.

Resume and Start go straight in for the same reason. The overview that
asks whether you meant it belongs to opening a link to a session you
have not seen, not to a button you came to this page and chose.

The actions moved out of the page heading and into the card that holds
the result — Repeat session, Review answers, Resume session — because a
row of buttons beside the title puts the decision as far from the
figures it follows from as the layout allows.

Delete session stays, but not as a red button next to Resume. It throws
away answers the analysis is built from, and it is the one thing here
AMBOSS has no need of — sessions are made freely, so a mis-made one is
clutter worth removing. It is a quiet control at the end of the row.

The session rail was sticky at top:0 with height:100vh, which started it
underneath the 98px header and ran it past the bottom of the window, so
the end of the list and the scroll that would reach it were never on
screen. It measures from --app-header now, and a wheel over the list
moves the list rather than carrying on into the page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 23:03:09 +02:00
Daniel
76848c5947 fix: the reading browser counted the wrong thing, and the hover card fled
The number beside a folder in Topic reading was a question count while
the browser lists articles, so "Hyperinflammatory Sepsis 4" meant four
questions and opened onto no reading at all. It counts what it opens
now, rolled up over the subtree, and a branch with nothing to read in it
is not offered — a folder with a number on it is a promise.

The hover card could not be reached. Its body was pointer-events: none,
on the idea that a hint should not sit between the reader and the link —
but the card is offset below the link and never covered it, while the
pointer travelling down to Split view crossed a body it could not enter,
so no mouseenter fired and the hide timer closed it on the way. The card
takes the pointer now, with a bridge across the gap.

And clicking the words opens the card rather than the article. A
cross-reference is read mid-sentence, and navigating away to find out
whether it was worth following is the thing that breaks the thread; the
card's two controls — beside what you are reading, or a tab for later —
are how you go. That also gives touch a route, where hover has none.
Modified and middle clicks are still the browser's.

The listing sent content and sections for all 331 articles, 214KB of
prose a list never renders. It sends what a list needs, which is 21KB.

The footer sat wherever the content stopped, so a page still loading put
it halfway up the screen with background below it. The shell is a column
the height of the window.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 21:24:04 +02:00
Daniel
fc4df94a6a feat: the tutor is an administrator's to allow, and a handbook explains the rest
The tutor is handed the correct answer and the explanation and told it
may reveal them, which is why it has never been offered during a running
exam — require_question_access already refuses that, whatever anyone
sets. What was missing is the other half: an administrator can now
withhold it from study sessions too.

Enforced on the server rather than by hiding a button, because hiding a
button does not stop a request. Reviewing a finished attempt is not
"during" and is unaffected; the answers are shown by then anyway. If
Redis is unreachable the tutor stays on — nothing is revealed that study
mode does not already show, so the permissive direction is the safe one
here.

GET /teach/prompt renders the instructions against a stand-in question,
so an educator answering "why did the tutor say that?" can read them
rather than infer them.

And a handbook at /handbook, for anyone who maintains questions or
articles whatever access they hold. It answers the things that were only
in the code: that a question links to an article three different ways —
a further-reading row, a key point carrying an article and section, and
a [[id|label]] marker in prose keyed by id so renaming does not break it
— what the tutor is told, why a blueprint shapes a paper, why deleting a
question hides it, and why changing the embedding model invalidates
every vector.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 21:13:38 +02:00
Daniel
c59f9f936b refactor(settings): People and models rebuilt natively; AdminPage is gone
The boxing was structural, not cosmetic. AdminSection wrapped AdminPage
in a Settings panel while AdminPage brought its own .card wrappers — a
box inside a box — and SettingsPage.css patched the symptom with
.set-admin > div > .card { border: 0 }. Both sections are written as
Settings sections now, the patch is deleted, and AdminPage with it.

People: no more refetch-everything. The old page called loadData() after
every change, pulling users, models and settings together and
re-rendering the section, which is what read as a page reload — there
was never a location.reload() to find. A change now updates the one row
it touched from what the server returned. The list searches, scrolls in
a bounded box, and will not let you change or delete your own account.

Models: the selection is rebuilt rather than restyled. Seven cards of
rows, each with Default, Test, Disable and Remove, meant four buttons to
express one fact. There are two decisions, so there are two places now —
which models the site may use at all is an allow-list you set once and
fold away, and which one does a given job is a single choice made where
the job is named. A job with one model offers no choice, because there
is none to make.

The embedding model moved in with the models, since it is one, and
Search — which held nothing else — is retired.

Settings fills the window: the panel column scrolls inside itself, so a
short section no longer ends two thirds of the way down a tall screen
and a long one no longer runs off the bottom. Below 700px the page
scrolls as one, as before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 20:39:38 +02:00
Daniel
3d379d41a8 refactor(settings): drop Safety and the classifier rollback, collapse the Access tree
Safety held one thing: rolling back the AI classification tag assignments. The
classifier is gone, so the panel had nothing left to be about and the section is
removed, along with the snapshot state and the rollback call that fed it.
Permissions — the other thing that section might have grown into — already have
a page in Access.

"Search and sign-up" carried two controls that live somewhere else: a Public
Registration toggle that Access and joining already owns, and an embedding model
field that belongs with the other models. The duplicate registration toggle is
gone and the section is now just Search.

The account menu no longer offers Administration. It pointed at /settings?s=people
— the same page the Settings entry above it opens — so it was two names for one
door. Settings already shows the site sections to an administrator.

Access: the branch tree opened its top level by default and ran to hundreds of
rows, which buried the image libraries below it. Every branch now starts closed,
opening one closes the one before it at the same depth, and the tree scrolls
inside a bounded box. Picking a different person collapses it again. A "Clear all
access" control removes every grant a person holds, including the everything
role, for starting over.

The standfirst is reworded to lead with what to do rather than with a definition.

Tests updated rather than worked around: the Settings test asserted Safety was a
section, and the Access test reached a child branch that is no longer open on
load. Both now assert the new behaviour, plus two new cases — that branches start
collapsed, and that opening one closes the previous. 346 frontend tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 20:27:55 +02:00
Daniel
5c4823d9a4 feat: exams are a group you can build, with the board's own blueprint
Four gaps, one change.

Articles could not belong to an exam at all — an article reached one only
by inference through its category, which cannot say that the same article
belongs to a basic-science step and a clinical one showing different
views in each. article_exam_links says whether it is in the group;
Exam.article_views already decided what is shown once you are there.

POST /exams/ wrote name, slug, sort order and active, and silently
dropped family, description and article views, so a new objective landed
in "Other" showing everything whatever was asked for. It writes what it
is given now, and PATCH can change it afterwards.

Membership was one link row at a time, which nobody would do for three
thousand questions. POST /exams/{id}/assign takes whole topics with
everything beneath them — questions and articles both — and is
idempotent, so widening a selection and running it again adds only what
is new.

And the point of all of it: a real paper is not a uniform draw. The ABP
publishes that 12% of a general paediatrics exam is preventive care and
2% is rheumatology; forty questions drawn evenly is forty coin flips.
exam_blueprints holds a board's published outline — its own numbering,
its headings, its weights — and blueprint_category_links maps it onto
our taxonomy rather than bending the tree to fit, because their outline
is arranged for examining and ours for studying.

The sampler uses largest-remainder, so twenty-four percentages still come
to forty questions, and a domain that cannot supply its share gives the
shortfall back to be spread over those that can — the paper keeps its
length and loses only accuracy, and the working is returned so the
shortfall is visible rather than silent.

Seeded from the ABP General Pediatrics Content Outline (Oct 2024):
structure and published weights only, no exam material. 120 lines, 22 of
24 domains mapped; Psychosocial Issues and Child Abuse and Neglect have
no category of ours and are reported rather than hidden.

Creating an objective is now an administrator's rather than a
moderator's: it appears in everyone's picker and scopes the whole bank,
which is site configuration, and it sits with the other site switches a
moderator cannot reach.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 20:27:48 +02:00
Daniel
d1de9589ad feat: questions are soft-deleted, and the trash holds them
Question ids come from a sequence and are never reissued, and fourteen
tables point at them — attempts, quiz membership, exam membership,
media, article links, notes, favourites, feedback. Deleting the row took
all of that with it, so "restore" could only ever have meant typing the
text in again as a different question.

DELETE now sets deleted_at. The question leaves the bank, the builder,
search and every share path at once, because the exclusion lives in
general_question_predicate rather than at each call site. Restoring puts
back the same id, so everything that pointed at it still does. Erasing
for real requires the trash first and a moderator, and the confirmation
says what goes with it.

The trash page holds questions instead of tests. A test is a selection
you can remake in a minute; nobody wanted those back.

Used and withdrawn invite codes can be removed — an unused one is still
withdrawn rather than deleted, so it stays visible as having been issued
and stopped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 20:12:58 +02:00
Daniel
6b6c5e1b49 fix: the objective menu was clipped out of existence; one door to settings
The section bar is 46px tall with overflow hidden, so an absolutely
positioned menu inside it was cropped to a strip and never appeared —
the same reason it looked wrong in a settings panel, which also clips
its own overflow. The menu is fixed now and the component measures the
button and places it, following resize and scroll.

In a settings panel there is no menu at all: the objective is stated
plainly with a Change button that opens the picker, which is an overlay
fixed to the viewport and so cannot be clipped by anything.

The taxonomy page no longer searches the question bank. Filing a
question is done where the question is; a search box on a taxonomy row
was a second, worse question bank.

The account page is gone. Settings opens on the account, so Account and
Settings were two doors to the same room; /account redirects. Admins get
an Administration entry in the person menu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 20:03:11 +02:00
Daniel
2c5d3c67b4 feat: one settings page, and comments removed
Settings linked to a second dashboard with its own tab bar and its own
visual language. The admin sections are rendered in Settings now, under
headings that say who they are for — You, Content, The site — and each
has its own address, so People, AI models, Safety and Search are links.
/admin redirects into Settings for anyone who bookmarked it. AdminPage
takes a `section` prop and drops its tab row when embedded; it is loaded
lazily, so it is not in a learner's download.

Comments are gone: router, model, table and the half of the test file
that covered them. They were a discussion thread nobody was obliged to
answer, and feedback replaced them with a message addressed to whoever
maintains the question. The table was empty, so nothing was lost —
verified before dropping it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 19:40:24 +02:00
Daniel
c20b1e678f feat: an objective menu, no unscoped choice, and pages that survive a deploy
The switcher opened the whole picker on the first click. Now it opens a
short menu: what you are studying for, the objectives you have been on
lately (one click to switch back), and a way through to the full list.

"All content" is gone. Studying for nothing in particular is not an
objective, and the whole bank at once makes the filters and the analysis
mean less rather than more. Saving requires an objective to be chosen.

Suspending a session now lands on that session's analysis, where what
has been answered so far is scored and Resume sits, rather than on a
list of every session you own. A course quiz still returns to its course.

lazyPage() replaces lazy() for every route and lazily-loaded component.
Each build fingerprints the chunk filenames, so a tab still holding the
previous index.html asks for a file the new image does not have and the
user meets "This page failed to load" for a page that is fine. One
reload per tab fetches the new index; a failure that survives the reload
is shown, not looped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 19:31:13 +02:00
Daniel
5f61422daf feat: a share dialog that shows what is being shared
A copy button tells you nothing about what you are about to send. The
dialog names the session, counts its questions and shows the stem it
opens on, then offers the link with Copy and the places people actually
send one — email, WhatsApp, Telegram.

Sharing is the administrator's to allow. GET /quizzes/share-policy is
asked before the dialog offers to make a link, so a switch that has been
thrown reads as "not offered" rather than as a button that fails when
pressed. A link already issued keeps working either way.

Removes the second, lesser share block that sat inside the save panel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 19:24:45 +02:00
Daniel
2267f53b55 feat: the player is a box; sharing and sign-up are the administrator's
The quiz player is a box the height of the window. The question used to
scroll the whole page, which took the session rail and the navigation off
screen exactly when you wanted them; now each column scrolls on its own
and the bar — Exit session, Previous, Next, Review — stays put.

Two site-wide switches, together under Settings → Site policy because
both are the administrator's and both apply to everyone:

  * Sharing can be turned off. That stops new links being made; one
    already handed to somebody keeps working, since revoking it would
    break something a learner has already given away.
  * Sign-up can be made invite-only, with single-use codes carrying a
    note of who each is for and, afterwards, who it let in. A spent code
    is kept rather than deleted — that record is the point of invite-only.
    The alphabet has no O/0 or I/1/l, because these get read aloud.

The registration form asks for a code only when the site needs one, via
an unauthenticated policy endpoint — it has to know before there is an
account to ask with. It never says whether a given code is valid before
the account exists, which would make it somewhere to guess them. The
first account is always allowed, or a new install would lock itself out
before an administrator existed to issue a code.

Flags fall back to their defaults when Redis is down, in the safe
direction each way: sharing keeps working, sign-up does not silently
open.

Found on the way: the registration form's three labels named nothing —
no `for`, no wrapping — so a screen reader announced unlabelled boxes.

Backend 261/261, frontend 328/328.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 19:17:35 +02:00
Daniel
392a2cc483 feat: feedback replaces comments; Qbank is a landing page; one question page
Comments are gone. A thread under every question was a discussion nobody
moderated, and what it was used for was telling an educator something was
wrong. That is now feedback: a private report, carrying the question id,
that someone is expected to act on.

  * Give feedback sits in the question bar's new "more" menu, beside Save
    and Share — occasional actions, folded away rather than each taking a
    slot in a bar read on every question.
  * An educator gets a badge of what is outstanding. Each row names the
    question and opens its editor, where the report sits beside the field
    it is about; reply, resolve, reopen or delete from there.
  * Resolving keeps the report. A question with a history of the same
    complaint should visibly have one; deleting is for the ones that were
    never about the question.
  * A granted educator sees only their own branch. The badge answers
    quietly with zero for someone with no access, so the header can ask
    without first working out who is asking.

The question bank is now the Qbank: create a session, and the last three
with Resume. Its facets, tag tree and create-a-quiz were a second copy of
the custom-session page; marking and folders belong in the player while
you are sitting a question. Import and export moved to the question
manager, which is the one place questions are managed, and which now has
a Preview that opens over the list instead of a page you have to come
back from.

Fixed while there: a session in progress analysed as 0/0 with an empty
table, because the analysis read attempt_answers — written on submit —
while the session list counted the saved progress. They read the same
thing now. The category trail is gone from the player: it named the
answer's own topic and led out of a session part-way through. An option's
reasoning opens on click and closes on the next one.

Backend 253/253, frontend 323/323.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 18:50:57 +02:00
Daniel
522af7181c feat: a real objective picker, and a section bar that fits
The study objective was a <select> capped at 220px, so it clipped
"Pediatrics Boards (2948)" mid-word, and it offered "USMLE Step 2 CK (0)"
as though it were ready — choosing it would have scoped the bank to
nothing. It is a dialog now: grouped by family, searchable, each option
saying how many questions stand behind it, an objective with none shown
but not selectable, and the change confirmed rather than applied the
instant the pointer crosses an option.

The section bar outran its width because four of its entries were not
about the material. Home is the signed-out landing page and is gone from
it; Dashboard, Account, Settings and signing out are about the person
and now sit behind one account control. Nine entries instead of
thirteen, which is the actual fix — the arrows added earlier make a long
bar usable, they do not make it right.

Frontend 320/320.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 18:31:37 +02:00
Daniel
73e9de8831 fix: a session left part-way could not be deleted, and dead space above the footer
`last_attempt_id` counts finished attempts only, so a session with a
live attempt and none completed was addressed as /sessions/q/{quiz} —
a route with no attempt id in it. Delete then sent
`/attempts/undefined` and nothing happened; Review answers pointed at
the same nothing. The page now uses the attempt its own analysis names,
and the rail prefers the live attempt over the last finished one, so the
address has an attempt in it to begin with.

The empty band above the footer was `padding-bottom` reserved for a save
bar that is sticky but still in flow and is the last element on the
page — so the padding sat *under* the bar rather than behind it. Gone
from the custom-test and question-edit pages. The question manager keeps
its padding: that bar is `position: fixed` and genuinely overlays.

Frontend 318/318.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 18:28:22 +02:00
Daniel
14a75303a7 fix: every cross-reference in the corpus was a dead link
`[[403|urethritis]]` became `<ArticleLink slug="403">`, which built the
href `/articles/s/403` — the slug route — and asked the preview endpoint
to resolve "403" as a slug. Neither exists, so the hover card never
appeared and the link 404'd. Every one of the 2,150 links is written by
id, because an id survives a rename and a slug does not, so this was the
whole library and not one article.

resolve_slug now takes an id as well as a current or historical slug,
and the link addresses the article directly when it is written by id.

Also: a view of one section no longer prints a heading repeating the tab
above it. "Short" over a heading reading "In short" says the same word
twice, and hid the only content behind a chevron. No collapse control
over a single section, and no contents list of one entry.

And a horizontal-overflow guard that only half worked: `overflow-x:
hidden` was on body but not html, so the browser could still propagate
the overflow to the viewport and scroll the whole page sideways — which
is how the navbar came to be clipped mid-word. The exam name now
truncates with an ellipsis instead of clipping.

Backend 242/242, frontend 316/316.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 18:25:31 +02:00
Daniel
e176068f26 fix: two strips that said "there is more" and gave no way to get there
The section bar hid its scrollbar and faded its right edge. A fade is
not an affordance: a vertical wheel does not move a horizontal overflow,
so with a plain mouse the last links — Courses, Settings — were simply
unreachable. ScrollStrip gives it arrows that appear only while there is
somewhere to go, maps the wheel onto the axis that actually scrolls, and
brings the current page into view on arrival. It renders as whatever it
replaces, so the bar stays a <nav> landmark rather than becoming a div.

The library's column browser had two faults. Its columns were sized
`clamp(220px, 32%, 320px)` — a percentage against the *visible* width,
so they shrank as more opened and the strip understated how much there
was to scroll. And opening a category added a column off the right-hand
edge with nothing scrolling it into view. Fixed width now, the newest
column scrolls itself into view, a spacer supplies the end padding that
a scroll container does not count, and the scrollbar is visible because
here it is the only affordance there is.

Guarded scrollIntoView and scrollBy — jsdom has neither, and the first
version of this broke seven navbar tests by assuming them.

Frontend 316/316.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 15:12:48 +02:00
Daniel
4a28b5e0a0 fix: new question opens the editor page; the bank previews rather than quizzes
Three things asked for.

New question opened a cramped modal in both places it was offered — the
question bank and the question manager — while Edit opened the full
page. Both now go to /questions/new, carrying where they came from so
the back link returns there. CreateQuestionModal had no callers left and
is gone.

The bank's "Study" action is "Preview", and shows the question whole:
the correct option, the per-option reasoning, the explanation and the
key points, all at once. Making someone answer first is the right shape
for practice and the wrong one in the bank, where the question is being
inspected rather than sat. Option reasoning and the explanation render
as Markdown there now too, instead of raw text.

Option explanations get the same formatting toolbar as the stem — an
option's reasoning is prose as well, and often carries a list or table.

Frontend 309/309.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 14:45:26 +02:00
Daniel
5575fe39bb feat: per-block counts on a study plan, not a status word
'1/1 article · 50/50 questions' beside each block, the way a course
module reports itself. Counts rather than a percentage: '0/6 articles'
is something you can act on, '0%' only tells you how to feel about it.
The state stays alongside for the one-word answer.

Frontend 308/308.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 13:34:56 +02:00
Daniel
3b45eaf3a6 feat: formatting buttons on the question editor — a toolbar, not a WYSIWYG
"How do I bold? add a list etc?" — answered, and the interesting part is
what it is not.

Milkdown was the obvious choice; it is already in the project. Round-
tripping a question stem through it first showed why not: bullets come
back as `*` with blank lines inserted between them, tables are repadded,
and anything it does not recognise is escaped. The first two reflow text
nobody edited — and learners' highlights are stored as character offsets
into that exact string, so a reflow on any save moves every one of them.

So the text stays byte-for-byte as typed and the buttons only insert
syntax at the cursor: bold, italic, code, bullet and numbered lists
(every selected line, and already-bulleted lines left alone), heading,
a table skeleton, inline maths. The Markdown preview that was already
there shows the result.

Frontend 308/308.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 13:20:59 +02:00
Daniel
101b03348a fix: the rich editor was escaping every cross-reference it touched
`[[387|Metabolic acidosis]]` is our own syntax, not CommonMark's, so
Milkdown's serializer treats the brackets as literal text and escapes
them on the way out: `\[\[387|Metabolic acidosis]]`. That renders as
literal brackets and resolves to nothing.

ArticleEditor uses RichEditor for section content, so opening an article
and saving it broke every link in it — silently, one article at a time,
against the 2,150 cross-references added earlier today. Nothing in the
database is damaged yet; nobody has edited an article since the links
were made. Found by round-tripping a stem through Milkdown while
investigating whether the question stem could move to it.

restoreMarkers is deliberately narrow: it only undoes an escape that
reconstitutes a marker we actually produce — a numeric id with a label,
or a bare slug — so an author who genuinely wrote `\[` keeps it. The
round-trip is now a test, so this cannot come back unnoticed.

Frontend 301/301.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 13:19:21 +02:00
Daniel
a5f5e3a536 fix: one notepad on the quiz page, not two
Per-question notes were already built and saved on blur. The global
notes tab was still floating over the same screen beside them, so it was
never clear which notepad a note was going into. The global note stays
where it belongs, on the dashboard.

The quiz test mock had no `put`, which is why nothing had ever covered
the per-question note path; it does now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 13:15:03 +02:00
Daniel
44af549dbc feat: the greeting varies, and one TODO closed against its own instruction
Four time bands rather than three — someone revising at 2am is not
having an evening — and the wording is keyed on the date, so it differs
when you come back and holds steady while you are here. A line that
changes under the reader on re-render looks like a glitch, not warmth.

Also closed: "References with in-text superscript markers". That was on
the list against an explicit instruction — "with refernec, but you dont
need in text reference". Leaving it open would have meant building it
eventually.

Frontend 293/293.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 13:01:42 +02:00
Daniel
765a477d4b feat: figures a question can actually have — managed, labelled, previewed
question_media replaced the two filename columns months ago: any number
of figures per question, each with a role, a label the prose can refer
to, a caption and an order. Only the editor's own endpoint ever read
them. The editor showed the two legacy text fields, and the player and
the answer review rendered the legacy paths — so the model existed and
nothing used it.

- FigureManager in the question editor: add from the image bank, name,
  caption, reorder, remove, per role. A figure with no caption is called
  out, because a caption is how anyone finds it again. The image id is
  shown, since that is what the link survives a rename by.
- FigureStrip on the player and the review. Explanation figures are
  labelled thumbnails that open full size and page between them — a
  stack of full-width radiographs between two paragraphs pushes the
  explanation off the screen, and "as in Figure 2" needs Figure 2 to be
  named where it sits. A stem figure stays full size: it is the question.
- question_figures.py is the single place rows become what a page
  renders, so the three views cannot disagree.
- Explanation figures are withheld until answers are revealed, the same
  rule the explanation itself follows.

The legacy paths still render where a question was never backfilled, so
nothing that worked before stops working.

Backend 242/242, frontend 290/290.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 12:59:43 +02:00
Daniel
994733581e feat: access as one tree — branches, libraries, and an honest "everything"
Access lived in three screens over two tables: category grants in the
question manager, media-library grants in the image bank, and nothing at
all for articles. Nobody could see what one person actually held.

/access is one surface over the same tables. A person on the left,
everything they have on the right. A granted branch shows its children
as covered rather than as separately tickable — a checkbox that changes
nothing is where a permissions screen starts lying — and the count of
categories a grant actually reaches is stated, not implied.

"Everything" is the moderator role, and the page says so instead of
inventing a wildcard grant that would silently mean the same thing and
be impossible to audit. While it is on, the branches below are hidden,
because they no longer apply. Nobody can change their own access.

The gap this closes: an educator granted a branch could edit its
questions but not the articles filed under it — articles were
moderator-or-author only. An article is filed under a category, so a
grant over that branch now covers its reading too. No new table: the
inheritance that category grants already had does the work.

Backend 242/242, frontend 284/284.

Also: the split-view test now focuses the link rather than hovering it.
Hover starts a 350ms timer; focus reveals at once, because the component
does not make a keyboard reader wait. That takes the wall clock out of a
test about the split view. Earlier failures were it losing CPU to the
backend suite running alongside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 12:53:56 +02:00
Daniel
9d407ca1d9 feat: settings as places with addresses; repair nested cross-references
Settings was one 600px column holding the account form, a theme picker,
a Nextcloud integration, a document list and an admin grid, in that
order, with no way to link to any of it. It is now a section list beside
one panel, with the section in the URL — so "change your password" is a
link and Back works. On a phone the list becomes a scrolling strip
rather than a second level of navigation.

- The exam objective moves in. It scopes the bank, the filters and now
  the knowledge profile, which makes it a setting; it was only reachable
  from a dropdown in the header.
- The notifications panel is gone. Its one control switched quiz
  reminders, and the reminder scheduler was removed earlier today — it
  was a toggle wired to nothing.
- Form fields are 16px on touch so iOS does not zoom the page in on
  focus and refuse to zoom back out; nav rows are 44px targets.

Also fixed, found in an agent's report rather than by looking:

  37 cross-references across 25 articles are nested and broken —
  `[[363|[[245|gastroesophageal reflux]] disease]]`, which renders as
  literal brackets and resolves to nothing. The first linker pass linked
  the longest title, then let a shorter one cut into the result. The
  current pass cannot do this (a finished marker is stashed), but the
  damage was already in the database and strip_owned could not see it:
  its label group stops at the first "]". link_articles now unwraps the
  inner marker, keeping the outer — the longer, more specific title.

And the ArticleSplitView flake: the preview card appears on a 350ms
timer and the query allowed 2s, which the full parallel run exceeded
often enough to fail a different case each time. Tried fake timers
first; they fight waitFor. A longer allowance is the honest fix — the
test is about the split view, not about how fast the box is. Four
consecutive clean runs.

Frontend 274/274.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 12:46:13 +02:00
Daniel
b06da68f6b feat: knowledge profile grouped by Articles, Systems or Disciplines
The same answers asked three ways, as AMBOSS does it: which reading to
go back to, which organ system is weak, which discipline is weak. It was
Systems/Subtopics, where "Systems" meant top-level categories — which
are disciplines, not systems — and "Subtopics" meant every category
below them.

  * Articles (the default): rows are the published article behind a
    category, so the row links straight to the reading.
  * Systems: the 16 organ systems. No question is tagged with a system
    directly — it carries a symptom keyword filed under one — so
    membership rolls up through the keyword's parent.
  * Disciplines: top-level categories, which is what the old "systems"
    grouping actually was.

Only 1,502 of 2,948 questions carry a system tag, so the Systems tab
says so rather than showing half the bank as if it were the whole of it,
and relevance there is measured against what the grouping can see.

"Practise this topic" now practises the row you are looking at, on its
own axis. That needed system_ids on the builder — matched as "any tag
beneath this system", where the existing tag_ids is "every one of these
tags", so the two cannot be conflated.

Backend 228/228, frontend 266/266.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 12:33:05 +02:00
Daniel
8f73e0f75b 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
2026-09-11 05:08:56 +02:00
Daniel
a677b4be23 feat: an exam that runs out is a result; delete and reset go all the way down
An unsuspended exam keeps running. When its clock runs out it is
submitted with what was answered and the score counts — a learner who
ran out of time sat an exam, which is a result and not an accident to
hide. Previously it was graded, flagged expired=1, excluded from every
statistic, and the client was told the opposite ("submit manually").

- attempt_expiry.settle_if_expired: one path, used by resume and by the
  sessions list, so an exam left open elsewhere shows its score rather
  than "in progress" forever. Suspended attempts hold their clock and
  never expire.
- resume returns {expired_submitted, attempt_id}; the client opens the
  analysis. The suspend dialog and the leave warning now say what
  actually happens.
- delete: saved progress and device lock cleared; a study-plan block
  whose only completed attempt is deleted goes back to unfinished.
- POST /attempts/reset-all: typed RESET, removes attempts, answers,
  in-progress state, plan progress, reading marks, saved questions and
  question notes; leaves the account, authored content and AI chats.
  Settings → Your data, with the counts reported afterwards.

Also fixed on the way: the first version of the sessions-list change
mutated the dict it was iterating; the test only passed because it had
one attempt. Now two.

Backend 223/223, frontend 258/258.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 04:47:11 +02:00
Daniel
ac92793600 feat: study-plan blocks as modules, sessions that know their block
From the three recordings and the AMBOSS screenshots.

Study plans
- Blocks of about 40, split evenly: 202 questions is six blocks of
  33-34, not five of 50 and one of 2. Reseeded (no progress or reading
  existed yet); the seeder now splits the same way.
- A block has its own page, laid out as a course module: the plan's
  blocks down the left, this block's reading then its session in the
  middle, back / previous / next along the bottom. Study or exam mode
  is chosen there, before the session exists; afterwards the mode is
  shown, not offered. The plan page is the table of contents and links
  into blocks rather than starting anything.
- Progress on a block comes from the same /quizzes/sessions row the
  Sessions page shows, so the two cannot disagree.

Sessions <-> plans
- A session started from a block carries its place in the plan: the
  session list and the analysis both return `plan` (plan, block,
  position, previous and next block). The analysis shows a strip with
  the way back to the block and on to the next one.
- Submitting a session marks its block complete. Nothing ever set
  completed_at before — every block read as unfinished forever.

Recommendations
- Framed by the learner's chosen study objective: answers and bank
  material linked to a different exam are left out, and the page is
  titled for the exam. Unlinked material stays in, as elsewhere.

Backend 216/216, frontend 257/258 (the one failure is
ArticleSplitView, which is timing-flaky under the full run and is
unrelated to this change; being checked separately).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 04:31:21 +02:00
Daniel
5a37c3d3f0 feat: sessions and analysis are one page, with the rail as the list
From the recording: sessions and analysis are the same subject, and
there should be no separate sessions page. The sidebar lists every
session, the top entry is the overall analysis, and each row opens that
session's own performance and recommendations.

- /sessions          overall analysis (was /analysis)
- /sessions/:attempt one session (was /analysis/session/:id)
- /sessions/q/:quiz  a session not yet sat — says so and offers Start
- /study/:id         taking a test (was /quizzes/:id then /sessions/:id)
- /study/new         building a custom test
- old /quizzes, /analysis and /analysis/session paths redirect

A session you have not answered no longer launches when clicked. It
opens its overview, which says nothing has been answered and that the
analysis fills in once it is sat through. Clicking a name in a list
used to drop the learner into a 240-question exam with the clock on.

SessionRail is shared by both analysis views; SessionsPage is gone and
so are the duplicate "History" and "Analysis" menu entries.

In the player, the counter that unfolded a grid of question numbers is
gone wherever the session rail is on screen — the rail already lists
every question and fills in as you go. Below 1150px, where the rail is
hidden, the dropdown remains as the only navigator.

Backend 208/208, frontend 252/252.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 04:18:04 +02:00
Daniel
ebb9e701ee fix: one sessions list, at /sessions, with plan material out of it
The page at /quizzes showed the same fifteen rows twice — once under a
"Sessions" tab as a list, once under a "Library" tab as cards — with
nothing distinguishing them. The navbar carried the duplication too,
with "Sessions" and "History" both pointing at the same page.

Board Review I-XII already exist as study plans. The Library tab was
showing the bulk quizzes those plans were built from, so the same twelve
titles appeared in both systems. Those quizzes are now origin='plan':
still real, still the parent of their questions via source_quiz_id, but
no longer offered as something to pick off a list. Once a learner has
actually sat one it is history, so the session list keeps it.

- QuizzesPage deleted; /sessions is the only listing
- /quizzes/* redirects to /sessions/*, preserving path and query
- submitting a session lands on its analysis, not the old score page
- the answer review drops its score hero, which the analysis owns and
  stated differently; a course quiz keeps its card, having no analysis
- delete-attempt moves to the analysis page, where the session lives

Backend 208/208, frontend 246/246.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 04:10:25 +02:00
Daniel
6993c06998 fix: a crashing page now says so, and the in-progress and stats cards are gone
/quizzes rendered a white screen — no navbar, no footer, nothing to read or
report. I could not reproduce it from the data shape the API returns, and that
is the point: an unhandled render error unmounts the whole tree and leaves
nobody, reader or developer, anything to work from.

There is now an error boundary around the routed page. The navbar and footer
survive, the message and the component trail reach the console, and the reader
gets a reload and a way out. It is keyed by path, so navigating away clears it.

Also removed, both superseded by the analysis page: the dashboard's in-progress
list and its three stat cards.

Still open on /quizzes: I have not found the underlying throw. With the boundary
in place the next visit will name it rather than showing a blank page, which is
the thing I actually needed and did not have.

249 frontend tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 03:48:12 +02:00
Daniel
1f77d421c7 feat: cross-link the articles, and strip what the analysis page replaced
The linking was the gap
The marker system was built weeks ago — resolves by id, survives a rename, shows
a preview on hover — and not one of 333 articles used it. Every article was
written in isolation, so a piece on croup named stridor and epiglottitis and
offered no way to reach either. `scripts/link_articles.py` reads what is written
and links it: 3,718 cross-references across 307 articles, by id, so a later
rename cannot break them.

Conservative on purpose, because a wrong link is worse than a missing one: only
the first mention in a section, whole words, longest title first so "Otitis media
with effusion" beats "Otitis media", never inside an existing link, marker,
heading, code span or table, and never an article to itself.

That exposed a second thing: the reading view had its own Markdown pipeline with
its own cross-reference regex, and it only understood the old slug form. It would
have printed every one of those 3,718 links as literal brackets. Article prose
now goes through the same renderer as the rest of the site.

Short and Clinical looked empty
Both are usually a single section, and everything starts collapsed, so the tab
showed one heading over blank space. A view of one section is not a contents
page; it opens.

Removed
Quiz reminders — emailed nudges to retake anything under 75%, with a scheduler
that existed solely to send them: the model, the service, the scheduler, the
email, the table. Article comments. The dashboard's in-progress list and its
stat cards, both of which the analysis page now answers better.

One mistake worth recording: the first pass at removing the reminder cleanup used
a regex that took 109 lines with it, including an unrelated endpoint. The test
suite caught it (`/attempts/quiz/{id}/in-progress` returning 404 instead of 403),
and the file was restored and edited by exact match instead.

208 backend, 249 frontend green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 03:44:44 +02:00
Daniel
6abe46d1ea fix: edit an image over the grid, drop the role from figure titles, no iOS zoom
Editing expanded the card in place, which stretched its column to the height of
a form and shoved every neighbour out of line. It opens over the grid now, with
the image beside the fields so you can see what you are describing.

Figure titles read "Stem figure — Occult Fracture". The role is already obvious
from where the figure sits, and the link is the `question_media` row rather than
the words in the title, so the title is now just the subject. All 440 are named:
346 lost the prefix, and 94 that were still filenames took the subject of the
question they came from — including detached ones, whose caption records which
question they came off. No link was touched; the id is the link, and all 346
remain.

iOS Safari zooms the page when a focused field's text is under 16px and does not
zoom back out, which leaves a reader stuck at 1.4x with no way back. Every
control is 16px on a coarse pointer — one rule, applied once, rather than
remembered per component.

249 frontend tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 03:28:09 +02:00
Daniel
f723a2fea4 fix: opening a session no longer launches it
Every link from the history went to /quizzes/:id, which auto-started. Clicking
"Board Review IX" out of curiosity began a 243-question exam, clock running.
That is why the links looked broken: they worked exactly as built, and what they
did was wrong.

Landing on a session now shows it — mode, length, and what the clock does — and
starts when asked. The session rows and the analysis rail open the session's own
analysis rather than the raw answer list, which is what a rail of past sessions
is for.

Sessions carry no category, so the row no longer prints one; that field was
still showing the retired "PREP" label.

Twenty tests asserted the old behaviour by mounting the player and waiting for
the stem. They now start the session the way a person does, which is the point:
the assertion was encoding a bug.

Settings is on the list. Before restyling it, the question is what belongs there
at all — several things have drifted in because there was nowhere else to put
them.

249 frontend tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 03:25:37 +02:00
Daniel
5add9f23dd fix: a skipped question is not a wrong answer
Performance by category counted every row in an attempt, and an attempt holds a
row for each question including the ones never answered. A 360-question sitting
that was opened and abandoned therefore landed as 360 wrong answers, which is
why Emergency Medicine read 0% of 400 and Gastroenterology 1.1% of 277 — figures
that describe a sitting nobody worked through, not a learner who cannot do
emergency medicine.

Accuracy now counts only questions that were actually answered, and the note
under the heading says so. Coverage is a separate question from accuracy and
conflating them made both useless.

Also: the category performance block is gone from the dashboard, where it
duplicated the one on Analysis; and the nav says Sessions rather than Quizzes,
with History beside it — "quiz" describes the packaging, a learner sits a
session, and the two entries answer different questions: what can I sit, and
what have I sat.

208 backend, 249 frontend green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 03:21:13 +02:00
Daniel
0e6c18d886 feat: figures as records, question-centred dashboard, fewer hints mid-quiz
Figures
A question could carry exactly one stem image and one explanation image, each a
bare path with no title, no legend, and no way for the prose to refer to it.
`question_media` makes a figure a row: it points at an image already in the bank,
carries a role, a label the text can name ("Figure 1"), a caption and an order,
and there can be as many as the question needs. The same radiograph can serve two
questions without being stored twice.

The 346 existing paths were backfilled into figure records and retitled —
`page_339_img_0.png` says where a file came from and nothing about what it shows,
so the filename moved into the caption where it is still searchable, and the
title became something a person can read.

On the editor question: no new platform needed. Milkdown is already installed —
ProseMirror-based, MIT, GFM tables, code blocks, LaTeX — and already used for
articles, courses and the quick question modal. Only the question *page* still
has plain textareas, and that swap is written down rather than rushed, because
the stem carries manual-highlight offsets and a WYSIWYG rewrite would move them.

Fewer hints during a quiz
The category trail and the difficulty pill were shown beside every stem. Being
told a question is filed under Neonatology, or that it is "hard", narrows the
answer before the stem has been read. Both now wait until the answer is in,
where the trail becomes a way to more of the same topic.

The dashboard is about questions
Quizzes and attempts describe how the material happens to be packaged. What a
learner is working through is questions: how many of the bank they have seen,
how many they have answered correctly, and their average. The old per-quiz
performance card — which needed two attempts before it showed anything — is
gone, superseded by the session analysis. The greeting sits above "continue your
study" rather than below it, where it read as a heading for the wrong section.

208 backend, 249 frontend green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 03:18:49 +02:00
Daniel
c4c0bb9df5 fix: left-align the category list, and drop a clause from the footer note
No reason for the centring — the rows inherited it from a panel that centres its
own content, and a column of names has to read down its left edge wherever it is
dropped. Set explicitly on the row rather than chased upstream, so the list stays
right in the next panel it lands in.

The footer's standing note is one clause now.

249 frontend tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 03:08:04 +02:00
Daniel
6bb5767871 feat: session analysis after a quiz, and a merged TODO for the rest
The results page showed a score and a wall of explanations. What a learner needs
afterwards is where the time went and what to go back to, so
/analysis/session/:attemptId gives them: a rail of recent sessions, the four
figures they act on — correct, completed, time per question, total time — a
donut, the weakest topics, and a paginated table of every question with its
status, difficulty, time and how peers did on it.

Time per question was not recorded at all, so it could not be reported. It is
now (`attempt_answers.seconds_spent`), banked when you leave a question and
including the one still open at submission — without that the last question of
every session would show nothing. Answers from before this read "—" rather than
claiming zero, and a question nobody else has answered has no peer rate rather
than 0%, which would read as everyone having failed it.

Also in this pass, from the review:
  * quiz categories are gone from the library — a second taxonomy beside the
    real one, putting a heading above every test;
  * the board review sets are numbered rather than dated, in both the quizzes
    and the study plans built from the same material, so a learner does not meet
    2019 in one place and VII in another;
  * the footer's standing note is one clause, and the gap above it no longer
    looks like the page ended early.

Everything else asked for today is written down in docs/TODO.md rather than
half-built: resume instead of restart, an unsuspended exam that keeps running,
deleting a session's data, reset-all-data with a warning, recommendations split
by article/discipline/system, and the adaptive session. Two questions I owe
answers to are in there too.

208 backend, 249 frontend green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 03:07:05 +02:00
Daniel
d34233d87b feat: attending tip, per-question notes, save to folder, session clock
The question toolbar now carries what a learner actually reaches for. An
attending tip — one sentence of the kind said at the bedside, stored separately
from the explanation because it is read before the answer is known and must not
give it away. A note of their own on that question, replacing a single global
note that was one page for everything and so was never about the question in
front of you. Saving to a folder, which the collections API has supported all
along with nothing in the player able to call it. And the share link, which
previously only appeared on the start screen.

Panels open one at a time under the toolbar; two at once would push the options
off screen.

Reset question resets one question, not the attempt: a misclick should cost the
answer you just gave, not the nineteen before it.

The clock shows session time, time on this question and the running average, in
study mode as well as exam mode — four minutes on one question is the number
that says whether you are learning or stuck, countdown or no countdown. It
pauses, because time spent making tea is not time spent thinking.

208 backend, 249 frontend green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 02:59:32 +02:00
Daniel
d1388f3335 feat: take a selection off inside the picker
A facet panel could add a choice but not remove one without finding its checkbox
again in a list of several hundred — and Reset, the only alternative, throws away
nine choices to undo the tenth. What is picked now shows as chips under the
search box, each removable on its own, with Clear all beside them once there is
more than one.

248 frontend tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 02:54:17 +02:00
Daniel
e63c625ed4 fix: the top bar's vibration, the session rail, and three pieces of old design
The bar vibrated because I made it fight itself
Collapsing it takes 46px out of the page, the browser corrects the scroll
position to compensate, and that correction arrives as an upward scroll — which
shows the bar, lengthens the page, and starts the loop again. The 6px jitter
guard could not help: these were real scroll events, just ones we had caused.
State now cannot change until the layout has settled, and a test reproduces the
echo rather than trusting the fix.

Session history, built properly
"Show all 15" expanded a list in place, which answers a smaller question than
the one being asked. There is now a real /sessions page: a rail that filters by
status and mode and sorts by recency, weakest or best, a running average across
what has actually been attempted, and nothing truncated. The quizzes page keeps
its preview and links straight to it. A session never attempted sorts last under
both "weakest" and "best" — the absence of a score is not a bad one.

The session rail is one row per question
A five-line excerpt per question turned a rail of twenty into a page of its own.
Number, then the stem on one line, truncated. The repeated "Question N" label
said nothing the number had not.

"Manage references and drafts" said nothing useful
"References" there means lab values rather than citations, and "drafts" means
unpublished ones — an editing switch hidden in a learner's lookup, phrased as
neither. It is now an "Edit values" toggle that says, while it is on, that
unpublished values are showing and that changes reach every learner.

Also gone: the "All past attempts" block, which the Sessions view had already
replaced.

248 frontend tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 02:51:46 +02:00
Daniel
7a764d8845 feat: a real site footer, and better retrieval queries
The links I put in the save bar are gone — that bar was right as it was, and a
row of navigation crammed above it was clutter in the one place a person is
trying to finish a question. The footer is where going somewhere else belongs.

`SiteFooter` replaces the copyright line: four columns — Study, Library, Find,
PedsHub — with About, Contact, Account and Settings among them, and the standing
note that this is revision material rather than clinical guidance, said once at
the bottom of every page. A test asserts every link points at a route that
actually exists, because a footer full of dead links is worse than a short one:
the reader learns not to trust any of them.

Two retrieval faults the writing found
A bare condition name is a thin query. "Rickets" alone retrieved five passages
about *Rickettsia* — an embedding has little to go on in one word, and the
nearest neighbours of a short string are whatever looks like it. Asking as
"Rickets in children: definition, causes, clinical features, diagnosis and
management" took the contamination from five passages to none, so both the
pipeline and the generated route now ask that way.

And a category that names a department rather than a condition retrieves chapter
headings and whatever sits near them. "Pediatric Nephrology" passed the material
check with entirely irrelevant passages, and an article called that is a
department, not something to revise. Those names are now excluded from the topic
list.

Both were found by an agent writing articles and reporting what looked wrong,
rather than by anything automated noticing.

247 frontend tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 02:40:57 +02:00
Daniel
16d799a273 feat: every figure in the image bank, bigger editing boxes, footer links
The figures were never in the bank
3,702 extracted figures have always been in object storage and attached to
questions by path, but were never rows in `media_assets` — so nothing could
browse, tag, search or reuse them. They are all in a "Question figures" library
now, pointing at the same keys the questions use: nothing copied, nothing moved.

Each is tagged by what it is doing, which answers the question that prompted
this. 341 are in use. 94 are tagged `detached` — the ones the stem/answer review
took off a question because the figure illustrated the answer — and they are in
the bank precisely so that decision can be looked at rather than taken on trust;
every one is also still recoverable from its question's version history. 3,267
were extracted from the PDFs and never used by any question at all.

No image file was deleted at any point. All 3,702 are in MinIO, 854 MB, and
spot-checked readable.

Bigger boxes, and Markdown where prose lives
A stem is a clinical vignette and an explanation is a teaching paragraph; 120px
meant scrolling a box to reread what you had just written. Stem 340px,
explanation 280px, option explanations 130px. Option explanations render
Markdown and maths like every other piece of prose on the site, and the stem,
explanation and each option explanation now have a Preview showing what the
learner sees — the difference between writing a table and hoping you wrote one.

Footer links
The save bar is where the eye already is when a question is finished, so the ways
onward sit beside it: back to where you came from, then the bank, the manager,
the image bank, the taxonomy and the reading library.

244 frontend tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 02:32:06 +02:00
Daniel
07eae7937b feat: question manager edits on a page, and articles written without a writing API
The manager still opened a modal
The bank was moved to the full editor a while back and this page was missed, so
editing from the manager still meant a dialog whose category control was a flat
select of seven hundred breadcrumb strings — no search, no way to pick a branch
and then narrow within it, and too small to follow. The full page already has
the searchable drill-down with sub-selection, images, versions and option
explanations. Edit now goes there and carries the way back, filters and page
intact. The modal stays where a quick correction belongs.

Articles, written rather than generated
Per the user's instruction: no OpenAI, no OpenRouter for writing — bge-m3 for
the search and nothing else. `scripts/article_pipeline.py` splits the job so
only the machine half is machine work:

    topics  — conditions that still have no article, biggest first
    fetch   — embed the topic, search the library, write the passages and the
              references derived from their metadata to a file
    import  — take a finished article and store it as a draft

No model API is called at any point in that pipeline. Whoever writes the prose
reads the passages and writes original text from them; the references still come
from what retrieval actually returned, so they cannot be invented by the writer
either — the same property the generated route had, kept.

The importer refuses an article missing any of short, long or clinical. A view a
reader is offered and finds empty is worse than one that was never promised.

244 frontend tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 02:02:57 +02:00
Daniel
a1459b2965 refactor: name the study plans ourselves, and stop reserving 64k tokens a call
"PREP" is the American Academy of Pediatrics' trademark for their own product.
The plans here are our own sets of questions grouped by year, so they are now
named for what they are: Board Review 2021, and Mixed Review for the plan that
draws from every year at once.

Renamed in the database as well as the code — 13 plans, 14 quizzes a learner had
already generated from a block, and the 12 year tags, which appear in the
question bank's filters and are as visible as the plans. The seeder matches both
the old and new names so a fresh import still finds its material, and the tagger
mints the new one so the next run cannot undo this. Prompts and comments that
described the source PDFs by that name now describe them by what they are.

The generation run's 377 failures were not a bug
Every call was reserving the model's full 64k output ceiling, and OpenRouter
refuses the whole request when the balance is below the reservation — "you
requested up to 64000 tokens, but can only afford 52017" — however short the
answer would actually be. `_call_model` now takes a max_tokens, and the article
writer asks for 4000, which is comfortable for three views of one topic and
keeps each request small enough to be affordable. 98 articles were written
before the balance ran down; 158 exist in total.

Generation is paused at the user's request while credits are topped up.

208 backend, 243 frontend green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 01:41:18 +02:00
Daniel
18afb138dc feat: edit every view and its sources, with a way back to any earlier save
An admin could already edit an article's prose, but not which view a section
belonged to and not its references at all — generation attached those and
nothing could touch them. And the revisions the API had been writing since the
CMS landed were unreachable from the interface.

Editing one view at a time
Short, Long and Clinical are tabs, each showing only its own sections with a
count on the tab. All three live in one list because they are one article, but
editing them together made it impossible to tell which version you were
changing, and a stray edit to the clinical view while meaning to fix the long
one is a mistake nobody notices until a learner does. A parent can only be an
earlier top-level section of the same view, which is what the server enforces.

Deleting a section lifts its children rather than taking them with it: a
survivor pointing at a section that no longer exists is worse than an orphan.

References are editable and structured
Title, author and pages, so the editorial queue's "published without sources"
stays a truthful question. A save that does not mention references leaves them
alone rather than clearing them, or an older client would silently strip the
provenance generation attached.

Version history
Every save is listed with what it was, and any of them can be opened or put
back. Restoring is itself a save, so the version you are leaving is kept too — a
history you can only walk one way is not a safety net, it is a trapdoor. Someone
else's draft returns 403 rather than being readable through its history.

One bug this turned up: the page-number field was derived from the parsed array
on every keystroke, so typing "12, 14" became "1214" the moment the comma
landed. The field now holds what you are typing and the array holds what gets
saved, and on blur it shows what was actually stored so a dropped entry is
visible rather than a silent difference.

208 backend, 243 frontend green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-10 18:27:52 +02:00
Daniel
e1b580386c feat: short / long / clinical, and stop broad topics retrieving index lines
Renames the middle view to Short and puts it first: it is the quickest way to
tell whether this is the article you wanted, and the full text is one click
away. Existing generated articles were migrated in place.

The prompt now asks for bullets that each carry a fact, because "X is important
to recognise" is a bullet that survives revision and teaches nothing.

The retrieval bug that made the last run mostly skips
The prose filter — drop chunks under 200 characters, since they are headings and
index lines — ran *after* taking the top fourteen hits. A broad query like
"Immunodeficiency" or a specialty name matches chapter titles first, so all
fourteen were index lines and the filter left nothing: the topic was skipped as
having no source material when the library holds plenty. Retrieval now asks for
five times what it needs and keeps the first passages that are actually prose.
Immunodeficiency went from 0 passages to 14, Pediatric Cardiology 0 to 14.

That is the same mistake the folder filter has a comment warning about — filter
inside the ranking, not after it — made two functions later.

Two things I got wrong and corrected rather than worked around: a `LIKE
'%key_points%'` check reported the migration had failed, when `_` is a
single-character wildcard and it was matching the title "Key points"; and a
variant count showing no Short sections was taken against the old image, where
Short was not yet a known variant and was being coerced to Long.

203 backend, 234 frontend green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-10 18:21:21 +02:00
Daniel
3980ecb7f8 feat: one Markdown renderer for the whole site, with LaTeX and highlights intact
Every piece of educator prose the platform stores is Markdown, and until now
only articles rendered it. A lab panel written as a table reached the quiz player
as a row of literal pipes, which is why the table conversion had to be held back.

`RichText` is now the single renderer: GFM tables, `$…$` maths through KaTeX,
images resolved through the uploads helper, external links opened safely, and
raw HTML escaped rather than executed — a stem can never inject markup into the
page around it. The question bank's `dangerouslySetInnerHTML` is gone with it.

Highlights were the hard part
Manual highlights and the read-aloud cursor are stored as character offsets into
the raw stem, and rendering Markdown destroys the one-to-one map a plain string
gave us. A rehype plugin puts it back: each text node in the output carries the
source offsets it was parsed from, so a highlight saved before this change still
lands exactly where it was drawn, and the selection arithmetic that reads
`data-start` needs no change at all.

Inside an inline-formatted run the rendered text is shorter than its source by
the marker characters, so an offset picked mid-run can be out by a few. Splitting
per text node bounds that to one node and keeps every node boundary exact —
stated in the code, because it is a real limit rather than an oversight.

With that in place the lab tables are applied: 79 stems, 82 panels. Question 3333
now reads as two tables with `3.5 × 10⁹/L` instead of `3.5 x 109/L`, and the
`inEq/L` and `mrnol/L` scanning damage repaired. Each change was snapshotted
first, so it is reversible from the question editor.

Six schematic illustrations
Drawn from scratch as SVG in `scripts/seed_illustrations.py` — bilirubin risk
zones, airway narrowing by level, dehydration bands, the fluid pathway, the
target sign, growth velocity. Each is captioned, tagged and searchable in the
image bank, and each says on its face that it is schematic and not a clinical
reference. They exist so the media library, picker and article figures can be
exercised against real files, and because an article with no figure looks
unfinished even when its prose is not.

234 frontend tests green, 11 of them new on the renderer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-10 18:12:37 +02:00
Daniel
025e5bb4ac feat: article CMS, three reading views, and articles written from the library
Standardises cross-references the way we agreed, and puts a CMS around articles
so hundreds of generated drafts are reviewable rather than merely present.

Links, made rename-proof
`[[7|Febrile seizures]]` resolves by id and displays the text — the id is the
part that must not change, the text is what keeps prose readable while you write
it. `[[old-slug]]` still resolves and is rewritten to the id form on save, not in
a migration: an article nobody has touched is not broken, and rewriting prose no
one asked to change is how an editor stops trusting the editor. Every slug an
article has ever had is kept, so a rename redirects instead of 404ing, and a save
reports markers pointing at nothing — at the moment the person who wrote the link
is still looking at it.

Three views of one topic
The full article to study from, the key points to revise from, the clinical view
to act from, with doses. They are views of one article rather than three
articles, so the numbers cannot drift apart and a question linked to the topic
still means one thing. Each section carries its variant; articles written before
this are the long view, unchanged.

CMS
draft → in review → published, with an author able to submit and only a
moderator able to publish. Every save snapshots what was there, restorable, and
restoring is itself snapshotted or the way back from a mistaken restore is gone.
The editorial queue is work rather than inventory: waiting for review, generated
and unread, published without sources, published with nothing to practise,
barely written. An empty bucket is drawn as good news, not as an alert.

Articles from the clinical library
The library index is 1.8M chunks of reference texts embedded with bge-m3 — the
same model PedsHub already uses, so our query vectors are directly comparable and
nothing had to be re-indexed. Retrieval supplies the facts and the provenance;
the model supplies the prose. References are built from the metadata of the
passages actually retrieved, never from the model, so a reference cannot be
invented — the same property that makes an AI Mode citation trustworthy. A topic
with fewer than three grounding passages is skipped rather than written from
memory. Everything lands as a draft.

Two things worth naming. The generated text is original writing grounded in those
books, not extracts from them: their facts are usable, their sentences are their
publishers'. And there are two Milvus servers on this host — the collection with
the data is the one reached as `milvus`, not the similarly named one on the other
stack, which I wired up first and which silently refused.

Also fixed along the way: `litellm==1.28.13` has been withdrawn from PyPI, so
requirements.txt could no longer be resolved from scratch and the image only
built because of a cached layer. Later additions go in their own layer until the
pins are refreshed.

182 backend, 223 frontend green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-10 17:13:07 +02:00
Daniel
9beafff0bf feat: AI Mode — a chat that cannot cite what it did not find
The design settled earlier, built as described: retrieval decides what may be
cited, and the server enforces it.

The model is handed a shortlist of at most fourteen sources from the learner's
own library and told to cite them by marker. Afterwards every citation it wrote
is checked against that shortlist and anything else is deleted before it is
stored or shown. A hallucinated citation is not unlikely here, it is impossible
— surviving is not a decision the model gets to make. A URL it invents is not a
citation either: only the marker form counts, so a plausible-looking link stays
in the prose citing nothing.

Retrieval reuses the hybrid search already in place, and each corpus keeps its
own visibility rules — the bank predicate and exam scope for questions, the
draft rule for articles, deck ownership for cards. A question source carries the
stem only: a chat that printed the answer would hand away the practice it exists
to prepare you for.

Curated links do the job they were built for. A retrieved row an educator tied
to another retrieved row is boosted, because two things somebody already linked
surfacing for one query is evidence rather than coincidence. Nothing is stored
for this; the boost lives only in that ordering, and the answer marks those
sources so the reader knows which claim rests on an educator's judgement rather
than on a ranking.

Citations are stored with the answer as filtered, so reopening a thread shows
the links it showed at the time rather than a fresh retrieval that may now rank
differently. In the page the markers become numbers and each number opens its
source; a section citation deep-links into that section.

Two smaller decisions worth naming: a question appears in the thread the moment
you send it and is handed back to the input if the answer fails, because typed
words are not something to lose on a 502; and someone else's thread returns 404
rather than 403, since whether it exists is not your business either.

182 backend, 206 frontend green — 16 of the backend tests are the citation
contract and the retrieval boundary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-10 15:19:24 +02:00
Daniel
2d2845fa0a fix: legible section bar, and a real page for editing a question
Colours
`.sr-only` was used in four places and never defined, so every label meant for a
screen reader was printed on the page: "Studying for" beside the exam switcher,
"Difficulty", "Questions", "Category for …". On the old dark bar the stray text
passed for a label; on the new light one it was grey-on-grey and looked broken.
Defined it, and restyled the exam switcher for the surface it actually sits on
now rather than for the dark bar it used to.

The section bar itself was white on white, reading as a stray row of links
rather than a surface: it now sits a shade off the page with a hairline shadow,
links take the text colour at 78% instead of the muted grey, and the current
section gets a tinted pill with an underline. When the links outrun the width
the strip fades at the right edge — a hard cut just looks like a broken layout,
and "Courses" was being sliced in half.

Editing a question
The bank's Edit opened a modal that could not show option explanations, images,
versions and categories at once, and the linked-questions list on an article
offered only "Unlink" — you could read a question there and have no way to fix
it. Both now open /questions/:id, the full page, which already existed and was
reachable from almost nowhere.

The page carries where you came from: back and Cancel return to the bank with
its filters, or to the article, rather than always to a bank you may not have
used. Its header is sticky, because this page is long enough that scrolling to
the stem loses the way out entirely.

The quick modal stays where a quick correction belongs — the question manager —
and its two tests moved onto the component itself rather than reaching it
through a page that no longer opens it.

198 frontend tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XeFQJXJTfHKTfbfsdxv57Z
2026-09-10 12:43:02 +02:00
Daniel
bbc35ce5d8 feat: two-bar header, with the section bar out of the way while you read
Eleven links, a search box, an exam switcher and a logout button were all
competing in one 52px row. Two bars, as on the reference site:

  * the primary bar — identity, search, jobs, account — never moves;
  * the section bar carries the exam switcher and the sections, and leaves while
    you scroll down, returning on the way up.

That bar is navigation: wanted at the moment you decide to go somewhere else,
in the way for every moment in between. Scrolling up is the gesture that means
"I am looking for something", so that is when it comes back.

Three details it needs to not be annoying. It collapses its height rather than
sliding away, so the page moves up with it instead of leaving a gap. Nothing
hides within 90px of the top, so a short page never loses it. And a movement
under 6px is treated as noise, because trackpads and momentum scrolling emit a
great deal of one-pixel jitter — without that guard the bar flickers on a
stationary page. Scroll handling is one animation frame per burst, and
`:focus-within` keeps the bar open for a keyboard user tabbing into links that
are visually gone.

On a phone the burger keeps the sections and the strip hides: two copies of the
same links competing for the same thumb is worse than one.

The article contents are now a rail rather than a card — sticky for the article's
whole length, scrolling on their own when there are more sections than screen,
and collapsible to hand their width to the prose, which is what a wide table
needs and nothing else on the page can give it.

196 frontend tests green, 7 of them new on the header.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XeFQJXJTfHKTfbfsdxv57Z
2026-09-10 12:14:50 +02:00
Daniel
8a07703ee2 feat: study plans you can open, work through, and edit
Thirteen plans were seeded with an API to serve them and nothing that called it,
so the whole feature existed only in the database. Two pages and the editing
endpoints it was missing.

/study-plans lists the plans with progress stated in blocks — "3 of 6 blocks"
is something you can act on, where "50%" only tells you how you feel about it.
/study-plans/:id is one plan: each block shows Articles, then Sessions, in that
order, because that is the order the block is meant to be done in.

Reading is now part of a block (migration f4a5b6c7d8e9). "Mark as read" is the
learner's own claim and reversible — someone who ticks the wrong row should be
able to fix it without an educator, and progress nobody can correct stops being
trusted and then stops being used. It is a separate table from `article_views`
on purpose: opening an article is not the same claim as having finished with it.
A draft article attached to a block is listed for the educator who can open it
and left out for everyone else, rather than offered as a dead link.

Editing is inline on the learner's own page rather than a separate builder, so
the thing being changed and the thing a learner sees are the same object.
Moderators create (as a draft — an empty plan is not something to put in front
of anyone), rename, publish, delete; add, rename, reorder and remove blocks;
move questions between blocks of one plan; attach reading found by searching
rather than by id.

Two places where the obvious implementation leaves the data wrong, both tested:
deleting a block out of the middle shuffles the survivors down, or the next
insert collides with a position nothing occupies; and reordering parks every row
outside the range before writing the real positions, because (plan_id, position)
is unique and the first move would otherwise collide with a position still held.
A partial order is refused rather than half-applied.

166 backend, 188 frontend green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XeFQJXJTfHKTfbfsdxv57Z
2026-09-10 12:10:39 +02:00
Daniel
50ddcafd82 feat: an image bank you can actually use
The media API has existed since the image-libraries work with nothing to call
it, so images could be uploaded but never described, tagged, or found again. Two
screens close that.

/media browses the bank by library, searches by what an image shows, and edits
title, caption, alt text, tags and library in place. The id sits on every
thumbnail rather than appearing on hover, because the id is what a question
refers to and you should not have to go looking for it. An image with no caption
says so — captions are what the search index is built from, so an undescribed
image is one nobody will find, and that is worth saying on the card rather than
in documentation.

The question editor asked for a filename typed from memory, which meant keeping
a second tab open or guessing. It now opens a picker over the same bank, showing
caption and tags on every result: two chest films are identical at thumbnail
size and the caption is the only thing that tells them apart. An image uploaded
from the picker goes straight onto the question — coming here to add a picture
and then having to find it again is a step that exists for nobody's benefit. The
"Browse image bank" link pointed at /images, a route that does not exist.

Permissions follow the API rather than reimplementing it: library creation and
deletion stay with moderators, describing an image does not, and an upload goes
into the library being browsed because a non-moderator must name one.

177 frontend tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XeFQJXJTfHKTfbfsdxv57Z
2026-09-10 12:04:06 +02:00
Daniel
885e8be417 feat: one search across reading, questions, cards and images
Five corpora were each searchable from their own page, which meant knowing which
of five pages held the thing you were looking for before you could look for it.
`GET /search` runs them together.

Visibility is never re-implemented here. Questions go through the same bank
predicate and exam scope as the question bank, articles through the same draft
rule, cards through deck ownership, images through library grants. A search page
with its own idea of who may see what is how private content leaks, so the tests
that matter are the boundary ones: a peer's search reaches neither another
user's unshared question nor their deck, and a draft is invisible to everyone
but the educator who wrote it.

A section hit is reported under its article, not beside it — ten sections of one
article are one result with ten places to start reading, not ten results burying
everything else. This is what the section index was backfilled for; each one
links straight to that section.

Results are grouped by kind rather than interleaved by score. A question and an
article are different kinds of answer, and a single ranked list makes you read
every row to work out which kind each one is. Snippets show the window around
the match rather than the opening of the document, because every document's
opening looks the same. A question found only by the semantic ranker says so.

The header box has two ways out: pick a suggestion and go straight to that
article, or press Enter and search everything. Suggestions are lexical and
prefix-first — a typeahead is finishing the word you are typing, and a semantic
neighbour of half a word is noise — and debounced 180ms so typing is not a
request per keystroke. One corpus failing is logged and returned as a gap in the
answer rather than a failed page.

154 backend, 163 frontend green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XeFQJXJTfHKTfbfsdxv57Z
2026-09-10 11:59:58 +02:00
Daniel
362c48926b feat: browse the library in columns, read articles section by section
Three things, all from how AMBOSS actually behaves rather than from a
description of it.

Row by row, two menus
The articles page is now the column browser itself rather than a grid behind a
"▼ All categories" toggle. Opening a topic opens its contents in the next
column, so the trail you took stays on screen and you can step back a level
without losing your place. Topics and the articles filed under them share a
column, because to a reader those are the same list — things this heading
contains — and only the icon separates a folder you can open from a page you can
read. Articles filed nowhere sit in the root column instead of being unreachable
for want of a heading. Under 720px it is one column plus a back button. Search
is a different question from browsing — you already know the name — so it still
answers with a flat list of matches.

Sections that collapse
An article is a reference you consult, so it opens as a contents page: headings
only, each expanding where it sits. A section may now sit under an earlier
top-level one (`parent_id` on the section JSON, absent on every article written
before this), which is how "ROS questionnaire" belongs to "Review of systems"
rather than standing alongside it. The contents rail nests the same way. Nesting
is refused where it could not render: its own parent, a parent later in the
article, a parent outside it, or a sub-section of a sub-section. A deep link
opens the target section and its parent — landing on a collapsed heading looks
like the link went nowhere. References are pinned last however they were
written; a reader scrolling for content should not hit the bibliography halfway
down.

Links that show where they go
`[[febrile-seizures]]` or `[[Febrile seizures|febrile-seizures]]` in article
prose becomes an in-app link that previews the target on hover: title, a couple
of sentences of actual prose with the markup taken out, and how much is there.
Following a link to find out whether it was worth following is the thing that
breaks a train of thought. Slugs, not ids, because that is what an educator
writes and it outlives a renumbering. One fetch per article for the life of the
page, a 350ms delay so crossing a link summons nothing, and no card at all on
touch, where a card would sit between the finger and the link.

Dead CSS for the old section modal and the always-open section block is gone —
nothing rendered those class names any more, and stale rules winning on source
order has bitten this page before.

146 backend, 152 frontend green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XeFQJXJTfHKTfbfsdxv57Z
2026-09-10 11:42:33 +02:00
Daniel
086d5605a5 feat: one screen for editing every taxonomy axis
The tags API could create, rename, reparent, delete and attach questions, but
nothing in the interface called it — an admin could only change the taxonomy
through curl. This is the screen for it.

/categories becomes Taxonomy, with five tabs: Topics, Systems, Symptoms,
Diseases, Subjects. Topics come from question_categories and the other four from
question_tags split by type, but that split is an implementation detail and not
something to make somebody navigate around, so it is one screen.

Symptoms is 4,262 rows. The old page drew the whole tree on load, which for that
facet is a second of layout and a wall nobody can read, so the tree now opens
closed with a chevron per branch, the root list is capped at 150, and searching
flattens to matches with the trail that leads to them.

Two details the tabs have to get right:
  * a symptom filed under a system has a parent that is not a symptom, so the
    system is drawn in as a heading — otherwise 726 of them lose their root and
    vanish. Headings are not editable from that tab; systems belong to Systems.
  * deleting differs by table. A category with children is refused server-side;
    a tag's children rise to its parent. The confirmation says which you get
    rather than promising one and doing the other.

"+ Questions" on any row searches the bank and attaches what you check —
POST /tags/{id}/questions for a tag, /questions/bulk-category for a topic. Both
land in the pickers immediately, since the bank and quiz builder read the same
endpoints this page writes to.

16 tests on this page (up from 10), 143 frontend green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017acfNLsJpnkvH3sCZSjMJM
2026-09-10 10:51:25 +02:00
Daniel
d509d3db52 feat: real organ systems, symptoms grouped by system, and admin taxonomy CRUD
Systems were never systems
The 27 top-level rows were disciplines and care settings — Cardiology,
Emergency Medicine, Neonatology, and a stray condition (Sepsis) — not organ
systems. Cardiology is a discipline; Cardiovascular System is a system. So the
facet was mislabelled, and there was no organ-system axis at all.

Both fixes, as asked:
  * that tree is now the "Topics" facet, which is what it always was;
  * "Systems" is a new flat axis of 16 organ systems, matching how AMBOSS keeps
    Systems flat while nesting Disciplines and Symptoms.

Tags can nest (migration e3f4a5b6c7d8)
`question_tags` gains parent_id and sort_order. A tag may sit under one of the
same kind (Surgery > Hand surgery) or under a system, which is how symptoms are
grouped by where they present. 726 symptoms are now filed under the system they
appear in; the remaining 3,536 stay top-level rather than being forced into an
approximate bucket. A false positive the dry run caught: "vision" was matching
"Health Supervision" — the same trap as erythema/erythematosus earlier, fixed
with a word boundary.

Admin can grow the taxonomy without a migration
POST /tags creates a top-level entry or a child; PATCH renames, reorders and
reparents, refusing a cycle; DELETE reparents children to the deleted tag's
parent rather than orphaning them, and can move its questions elsewhere;
POST /tags/{id}/questions attaches questions. Everything appears in every picker
immediately, because they all read the same endpoint.

Article sections were indexed but empty — `_rebuild_section_index` only runs on
save, so articles written before it existed had no rows. Backfilled: 10 articles,
28 sections, now embedded and searchable. Section-scoped question links already
worked (7 of 34 links name a section).

Tests: 10 new backend covering the tree shape, adding top-level and child
entries, kind rules, duplicate refusal, cycle refusal, rename/reparent, question
attachment, delete-reparents-children, delete-with-move, and the moderator gate.
141 backend, 136 frontend green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017acfNLsJpnkvH3sCZSjMJM
2026-09-10 10:44:39 +02:00
Daniel
04310c8980 feat: drill-down category pickers and a column browser, matching AMBOSS
The tree was nested in the database but every picker still rendered it flat as
breadcrumb strings — "Neurology › Seizures & Epilepsy › Absence Seizure" beside
"Genetics & Metabolism › Achondroplasia" — so the nesting was invisible and 780
rows competed for attention at once.

CategoryDrilldown (facet pickers, question editor)
Walks the tree one level at a time, matching how AMBOSS draws Disciplines and
Symptoms: chevron on the left, then the checkbox, then the name. Nothing is
expanded on load; expanding is a separate control from selecting, so opening a
branch never silently applies a filter; and one branch stays open per level, so
the list cannot grow back into the wall it replaced. Typing flattens to matching
rows with their trail, because when you type a name you want the row, not the
path to it. Counts are roll-ups, so the number matches what the filter returns.

CategoryColumns (articles page)
Replaces a 780-entry dropdown with side-by-side columns, as in the AMBOSS
library: choosing in one column opens its children in the next, so the trail you
took stays on screen. Under 720px it becomes one column plus a back button —
the same navigation drawn for the width available, rather than two columns
squeezed until neither is readable.

Touch targets are at least 44px throughout, and expand and select are separate
hit areas so neither is a near-miss for the other on a phone.

Tests: 136 frontend green, build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017acfNLsJpnkvH3sCZSjMJM
2026-09-10 10:34:58 +02:00
Daniel
eeadeb4a94 fix: category tree cleanup, mixed-content redirect, and category page clarity
Category duplicates
The tag→category conversion appended a parent name to keep labels globally
unique, so the tree was full of rows like "Cellulitis (Emergency Medicine)"
filed under Emergency Medicine. The breadcrumb already shows the parent, so the
suffix was noise in every picker. scripts/sanitize_categories.py strips it,
then merges siblings that collapse to the same name — including the
"Absence Seizure" / "Absence Seizures" pair. Applied to production after a
table backup: 491 renamed, 3 merged, 1078 → 1075, and a second run is a no-op.
Merging repoints questions, additional-category links, articles, decks, child
categories and grants before deleting the losing row.

Newly created categories not appearing
`/api/question-categories` (no trailing slash) 307-redirects to **http://**,
which the browser blocks as mixed content on an https page. Three callers used
the bare path, so the request failed silently into a catch and the list stayed
stale. Trailing slash added.

Category page
Rows now show questions filed directly here, the roll-up including everything
beneath, subcategory count, and an Empty badge for a leaf holding nothing — so
the shape of the tree is visible rather than inferred. On small screens it says
plainly that editing is easier on a desktop.

Tests: 136 frontend green, build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01365DYKu14YtsBKv2ycW6eG
2026-09-10 04:09:18 +02:00
Daniel
beedb76afb fix: bank stylesheet was never imported; drop quiz categories; rail and sessions
QuestionBankPage.css was not imported, so none of the filter-bar styling applied
— which is why "⚙ Filters" and the question count ran together with no card
behind them. Importing it fixes the whole bar, not just the spacing.

Quiz/test categories removed. PREP lives in study plans now, so the Categories
tab, the category selector in the session menu, and the handlers behind them
were managing a concept that no longer exists.

Sessions list shows the first six with "Show all" and a link to the full history
on the analysis page, instead of printing every session inline.

The analysis session rail now runs the full height of the viewport against the
left edge rather than sitting in a boxed card, so a long list is one continuous
column. Below 1100px it returns to a card above the content.

Tests: 136 frontend green, build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01365DYKu14YtsBKv2ycW6eG
2026-09-10 04:04:28 +02:00
Daniel
817cff569d feat: question version history with restore; TODO list
Editing a question now snapshots its previous state. The last 5 are kept — the
value is undoing a recent mistake, not an audit trail, and an uncapped history of
full question bodies grows without bound (migration a9b0c1d2e3f4).

A restore snapshots the current state first, so the restore is itself undoable.
History is gated by the same per-category grant that gates editing, so it cannot
be read by someone who could not have made the edit. The question editor shows
the versions with their dates and a Restore action.

Also added docs/TODO.md tracking everything requested and not yet delivered:
AI Mode and its citation contract, global search, study-plan editing and
articles-in-blocks, admin settings revamp, image libraries and question folders,
media management, nested article sections with references and per-section notes
and feedback, per-question notes and feedback in the runner, tutorial mode, the
per-question performance table, the Overview dashboard, systems subsystems, and
dropping "Pediatrics" as a discipline.

Tests: 6 new backend (snapshot on edit, cap at five newest-first, restore,
restore is undoable, refused without edit rights, unknown version). Full suites
green: 119 backend, 136 frontend, build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpfzbZ1QTLMeVYxM2kyq8m
2026-09-10 02:27:44 +02:00
Daniel
587b23db8f fix: reveal rail excerpts gradually; session rail on the analysis page
Runner rail was leaking upcoming questions
It showed an excerpt for every question in the session, including ones the
learner had not opened — so the case was spoiled before they read it, the same
mistake the article page was making with answers. A question now shows its text
only once reached; the rest are bare numbers.

Analysis page
Added the "Latest sessions" rail beside the analysis, collapsible, each row
linking to resume or review.

SessionProgress component
A single flat fill cannot tell "answered 15, all right" from "answered 15, half
wrong", which is the thing worth seeing at a glance. Progress now renders as
correct / incorrect / remaining segments, shared by the analysis rail and the
dashboard panel.

Tests: the rail test now asserts an unopened question shows no excerpt and
reveals one when reached. Full suites green: 113 backend, 136 frontend.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpfzbZ1QTLMeVYxM2kyq8m
2026-09-10 02:11:11 +02:00
Daniel
48f3ded222 feat: exams as real data, AI-mode matching, and a continue-study panel
Exams (migration v4b5c6d7e8f9)
"Pediatrics Boards" was a hardcoded checkbox that filtered nothing. Exams are now
rows: Pediatrics Boards and USMLE Step 2 CK ship seeded, and everything already
in the bank is linked to the boards. Membership is a link table, not a column,
because one paediatric cardiology question can count towards several exams.

The learner's choice lives on `users.active_exam_id`, so it follows them between
devices instead of sitting in one browser's storage. Choosing an exam scopes the
bank; a question with no exam links stays visible, since unlinked content is
unclassified rather than excluded. A switcher sits in the navbar.

AI mode — matching, never generating
Both entry points build a test from the educator-reviewed questions that already
exist, ranked against the request. Nothing is invented:
- POST /questions/builder/describe turns "what I want to study" into a test.
- POST /questions/builder/from-upload matches a document against the bank. The
  file is read in memory and never stored — it is a search query, not a source
  of questions, so there is nothing to retain or expire. 10 MB cap, 30 questions.

Handing a whole document to `websearch_to_tsquery` builds one enormous
conjunction that matches nothing, so text over 300 characters is reduced to its
most distinctive terms, OR-joined, before it reaches the lexical ranker.

Continue your study (migration w5c6d7e8f9a0)
A dashboard panel with the sessions in flight and the articles most recently
opened. `article_views` records one row per learner and article, written best
effort so a reading page never fails because a bookkeeping write did.

Tests: 5 new exam tests (active exams and counts, choice persisted and cleared,
unknown/inactive refused, bank scoping including unlinked questions, moderator-only
creation) and 7 for AI-mode matching (no questions created, invisible questions
excluded, no-match reported rather than an empty test, upload limits enforced).
Full suites green: 113 backend, 136 frontend, build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpfzbZ1QTLMeVYxM2kyq8m
2026-09-10 02:08:38 +02:00
Daniel
25109d756d feat: hybrid search for articles and cards; full-page question editor
Retrieval generalised beyond questions
`_text_for_question`, `embed_question` and `hybrid_question_ids` all hardcoded
the questions table, so there was nothing to call for an article or a card. That
layer is now corpus-agnostic:
- `Embeddable` mixin gives articles and flashcards the same embedding,
  embedding_model and embedded_at columns questions have, plus a weighted
  full-text vector (migration u3a4b5c6d7e8).
- `embed_record(row, kind)` is one code path for all three — they share an
  embedding space, so they must share the model and provenance rules too.
- `hybrid_ids(db, query, kind)` ranks any corpus; `hybrid_question_ids` stays as
  a thin alias for existing callers.
- Article and flashcard search moved off `ILIKE '%term%'`, which could not find
  a jaundice article from "yellow newborn".
- The retry task and full regeneration now sweep every corpus, and the health
  report breaks down current/stale/missing per kind.
- Articles embed on create and on edit, with failures left to the retry task.

Quoted phrases replace the keyword-only mode
`websearch_to_tsquery` already gives "absence seizure" exact-phrase semantics,
and the semantic ranker sits out a quoted query. That covers the one case a
keyword-only toggle was for — exact lookup — per query rather than as a sticky
setting whose every position returns a subset of the default.

Full-page question editor (/questions/new, /questions/:id)
Editing happened in a cramped modal. There is now a page with room for the stem,
per-option explanations, a searchable category picker with primary plus extras,
difficulty, and images. It shows the question's id with a copy button, and
Duplicate creates a variant without retyping the stem. `GET /questions/detail/{id}`
backs it, pathed under /detail/ so it cannot shadow the static routes.

Question bank filter bar restyled — the toggle and count read as one control
instead of two grey pills crowding the result count.

Tests: 101 backend green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpfzbZ1QTLMeVYxM2kyq8m
2026-09-10 02:01:35 +02:00
Daniel
db5af8d661 fix: segmented mode control was overridden by dead CSS
Two `.custom-test-modes` rule sets survived the earlier rebuild. The later one —
from the radio-card design that the sticky bar replaced — won on source order, so
the control rendered as two oversized separate boxes instead of a segmented pair.
Removed it, along with `.custom-test-actions`, which styled an action row that no
longer exists.

Also tightened the bar: smaller segment padding, and Refresh is now a quiet ↻
affordance rather than a grey slab competing with Create Test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpfzbZ1QTLMeVYxM2kyq8m
2026-09-10 01:46:28 +02:00
Daniel
9b51a17507 feat: flashcard decks share the question and article category tree
Questions and articles both pointed at `question_categories`; decks had no
category at all, so the three content types could not be filtered together and a
topic's cards were unreachable from its category.

- `flashcard_decks.category_id` references the same tree (migration
  t2f3a4b5c697), so one category now spans questions, articles and cards.
- `GET /flashcards/` takes `category_id` and includes descendants, so a parent
  category picks up everything filed beneath it.
- `PATCH /flashcards/{id}` files or unfiles a deck, refusing a category id that
  does not exist rather than storing a dangling reference.
- The cards page shows each deck's category as a selector.

Tests: 5 new backend (all three types resolve to the same id, descendant
filtering, file and unfile, unknown category refused, renaming leaves the
category alone) and 1 new frontend. Full suites green: 101 backend,
136 frontend, build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpfzbZ1QTLMeVYxM2kyq8m
2026-09-10 01:40:03 +02:00
Daniel
e9c8bfd204 feat: session rail with excerpts, and articles that read as one page
Quiz runner — session rail
The desktop sidebar was `display: none` in the player theme, so the only way to
move between questions was a dropdown that showed numbers and nothing else. It
is now the left rail a Qbank session has: one row per question with its number,
an excerpt of the stem, its difficulty, and a mark for bookmarked ones. The
active row is marked with aria-current, answered rows turn green, and the rail
sticks while the question scrolls. Below 1150px there is no room for both, so
the topbar dropdown takes over as before.

Article pages — read straight through
Each section opened in a modal overlay, which meant a topic could not be read
end to end: you opened a section, read it, closed it, opened the next. Sections
now render inline as one page under a sticky contents rail, with the last-edited
date, matching how the reference reads. Deep links still work — `?section=` now
scrolls to and highlights the section instead of trapping the reader in a
dialog, and the rest of the article stays visible around it.

`scrollIntoView` is called defensively: it does not exist in every environment.

Tests: 2 new runner tests (rail lists number, excerpt and difficulty; navigates
and follows the active row; marks answered after a study response is submitted).
Stem assertions are now scoped to the question card, since the rail repeats each
stem as an excerpt. Article tests assert sections are inline and no dialog
opens. Full suites green: 96 backend, 135 frontend, build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpfzbZ1QTLMeVYxM2kyq8m
2026-09-10 01:36:45 +02:00
Daniel
878e61c69b feat: facet filters on the question bank, category page, sanitised tags
Question bank filters
The panel was a dialog inside a panel inside an aside, with raw checkbox lists
truncated at `.slice(0, 40)` — so most of the vocabulary was simply unreachable.
Replaced with the same facet rows the test builder uses: Status, Difficulty,
Systems, Disciplines, Diseases, Symptoms, Articles, each opening a search +
checklist panel and summarising as "Name +N". Tag lists show the most-used first
and reach the long tail by search instead of hiding it.

Category management page (/categories)
Renaming, reparenting and delete-with-move used to live inside that filter
panel. They now have their own page: a searchable tree with question and
subcategory counts, create-under-parent, and inline delete that rehomes the
questions. A category is never offered its own descendant as a parent, and one
with subcategories refuses deletion rather than orphaning them.

Tag vocabulary sanitised (scripts/sanitize_tags.py, idempotent, --apply to write)
The tags were model-generated per question, so the same concept recurred with
different casing and pluralisation. Applied to production, after a table backup:
  83 renamed   (Adhd→ADHD, Ige→IgE, 46,Xx→46,XX)
  75 merged    (Absence Seizures→Absence Seizure, Food Allergies→Food Allergy)
  17 disease→keyword   27 unused deleted     6859 → 6740 tags
Symptom reclassification matches whole names only. Substring matching moved
"Whooping Cough" and "Rocky Mountain Spotted Fever" out of diseases, so the rule
now requires an exact match, and genuinely ambiguous terms ("seizure",
"jaundice", "murmur") are left alone rather than guessed at — misfiling a
diagnosis as a symptom is worse than an untidy vocabulary.

Test builder on mobile
The sticky bar's `margin: 4px -16px -100px` guessed the page's own padding and
overflowed when it differed; it now bleeds to the viewport instead. The bar was
also wrapping into three ragged rows — modes now span the top and Refresh sits
beside Create. Long facet names truncate rather than widening a row into a
sideways scroll, and panel rows got comfortable tap targets.

Tests: 9 new for the category page (reparent, descendant guard, delete-with-move,
default-to-uncategorized, subcategory refusal, create, search, server refusal);
the three category tests move off the bank suite with it. Full suites green:
96 backend, 133 frontend, build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014yhHB8Pc7oQqyqn2Vo9DXA
2026-09-10 00:13:31 +02:00
Daniel
519f2e572a feat: hybrid search on BGE-M3, with embedding provenance and a retry job
Search
- Retrieval was hybrid in name only: the keyword filter was applied to the SQL
  query, so results were the *intersection* of the two rankers. A question that
  matched the meaning but not the literal string could never be returned. It is
  now a union, fused with Reciprocal Rank Fusion (a text rank and a cosine
  distance are not on comparable scales, so RRF uses only their orderings).
- Added a generated `search_vector` tsvector + GIN index, so the lexical half is
  ranked full text rather than ILIKE substring matching.
- Chose Postgres + pgvector over OpenSearch/Elasticsearch: a search cluster
  would add a second datastore to keep in sync and a JVM on this host, to
  replace an index Postgres maintains inside the same transaction.
- Removed the keyword-only mode. It looks precise but silently drops the
  question that asks the same thing in different words.

Embeddings — measured on 500 real questions, using each question's own
explanation as a paraphrase query (known answer, no hand labelling):
  bge-small (local CPU, 384d)  R@1 0.840  R@5 0.953  186ms/query
  bge-m3 (LiteLLM proxy, 1024d) R@1 0.847  R@5 0.973   93ms/query
BGE-M3 wins on both quality and latency and needs no extra credential, since
llm.danvics.com already serves `openrouter-bge-m3`.

Three gaps this exposed, all fixed:
- Nothing recorded which model produced a stored vector, so changing models
  silently mixed incomparable spaces. `embedding_model` / `embedded_at` now
  stamp every vector, `GET /admin/embedding/health` reports current vs stale vs
  missing, and regeneration defaults to stale-only.
- The generator read the model from env while the stamp read a Redis override,
  so a vector could be labelled with a model that did not produce it. Both now
  resolve through one function, with a regression test.
- Embedding at creation is best effort, and a failure left a question invisible
  to semantic search forever. `retry_missing_embeddings` runs every 15 minutes
  via Celery beat and backfills missing or stale rows.
- Query embeddings are cached in Redis per model, so typing is not a network
  round-trip per keystroke.

`dimensions` is only sent to OpenAI's embedding-3 family; BGE-M3 rejects it.

Tests: 8 new backend tests (union not intersection, fusion ordering, per-ranker
failure degradation, provenance stamping, stale/missing accounting, generator
and stamp agreement). Full suites green: 95 backend, 127 frontend, build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014yhHB8Pc7oQqyqn2Vo9DXA
2026-09-09 23:45:33 +02:00
Daniel
d893b77cc8 feat: practise a topic from its article; tidy the lab panel
Article page — cross-checked against AMBOSS's Library and article pages, which
drill Library → Clinical knowledge → Pediatrics → topic → article and never
list a topic's questions. You reach questions the other way, via "Start Qbank".

Ours printed every linked question on the reading page with its correct answer
and explanation, so opening an article spoiled its questions before the learner
ever attempted them. Replaced with a "Practise this topic" panel: how many
questions are linked, a count and mode picker, and a Create test button that
builds a test scoped to the article. Educators keep a collapsible membership
list for unlinking — stems only, no answers.

Lab values panel:
- Removed the Sources footer and its article deep links, as asked.
- One weight per row: the test name carries it, the range no longer competes.
  Ranges use tabular figures and the age column lines up in a grid instead of
  wrapping raggedly; the reference note no longer promises source links.
- Dropped the CSS for markup that no longer exists.

Tests: 5 new frontend tests for the practise panel (offers a test without
revealing stems/answers/explanations, article-scoped builder payload, error
handling, hidden for learners when nothing is linked, educator unlink); the
article and lab tests now assert those leaks are gone. Full suites green:
88 backend, 127 frontend, build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014yhHB8Pc7oQqyqn2Vo9DXA
2026-09-09 19:34:49 +02:00
Daniel
1f2d0a81a1 fix: two-card builder layout with a sticky mode and Start bar
Matches the AMBOSS screenshot: "Set session topics" and "Session criteria" sit
side by side, the question count lives at the bottom of the criteria card as
"20 /64", and session type plus Start are a sticky bar at the foot of the
viewport so neither is behind a scroll.

- Two-column grid, stacking to one column under 900px.
- Facet rows now lead with a + affordance and show the selection as a chip
  ("Cardiology" "+1"); an unset facet stays plain "All".
- Reset moved to the top right with its ↺ icon.
- Adaptive session is its own row inside the criteria card, with the spark mark
  and toggle on the right.
- A More expander holds what AMBOSS does not have — sharing, the exam time
  limit, and the unused/incorrect explanation — instead of stacking them.
- Mode is a segmented control in the bar rather than radio cards in the flow.

Tests: 2 new frontend tests pinning the count to the criteria card and mode plus
Create Test to the sticky bar, and the time limit appearing under More only in
exam mode. Full suites green: 88 backend, 122 frontend, build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014yhHB8Pc7oQqyqn2Vo9DXA
2026-09-09 19:08:20 +02:00
Daniel
075fd3a648 fix: rebuild the custom test builder as AMBOSS facet rows
Interacted with AMBOSS's custom session builder to copy the actual pattern.
It never shows facet options inline: each facet is one row carrying its current
selection, and clicking it opens a panel with a search box, the helper line
"By default, all X are included unless filters are selected", an "Include
questions from:" checklist, and Reset / Done. A row with several selections
reads "Cardiovascular System +1", and the available count updates live.

Ours had the opposite: a narrow sidebar of oversized stacked headings with two
separate inner scroll panes, so Disciplines and Symptoms each showed their own
scrollbar and the whole column fought the form beside it.

- New FacetPicker + FacetRow components: slide-in panel on desktop, bottom
  sheet on mobile, Escape and backdrop close, per-facet search and reset.
- CustomQuizPage is now a single 720px column: Set test topics (filter search +
  Exams / Systems / Disciplines / Symptoms / Articles / Saved rows), Test
  criteria (title, adaptive toggle, Difficulty and Status rows, sharing),
  Question count with the live pool, and Test type as two radio cards.
- One cross-facet "Filter search" lists matching options from every facet and
  toggles them in place, matching AMBOSS's search-first entry point.
- Difficulty and Status moved out of the sidebar into their own pickers; mode
  is radio cards rather than a select.

The builder payload and the /questions/builder contract are unchanged.

Tests: 4 new frontend tests (facet summary including +N, close-keeps-selection
and per-facet reset, cross-facet search toggling, reset-all-topics); the
existing builder tests now drive the pickers. Full suites green: 88 backend,
120 frontend, build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014yhHB8Pc7oQqyqn2Vo9DXA
2026-09-09 18:59:27 +02:00
Daniel
16dc431066 feat: analysis recommendations, category grants, compact question header
Analysis / recommendations (AMBOSS parity, verified on next.amboss.com):
- GET /study-tools/recommendations ranks focus areas by the study time most
  likely to raise the score. Readiness is the learner's accuracy in a category
  shrunk toward their own overall accuracy in proportion to sample size, so two
  unlucky answers do not read as a knowledge gap; it unlocks after 40 answers.
  Relevance is the share of the bank a category holds. Counts roll up through
  the category tree, so a system inherits its children's questions.
  It is deliberately not called EPC and does not claim to predict an exam.
- New /analysis page: Performance and Recommendations tabs, readiness summary,
  adaptive-session box, and expandable focus rows showing questions seen,
  answered correctly, the linked article and a per-topic practice action.

Per-category educator grants:
- category_grants table (migration p8b9c0d1e253) plus utils/category_grants.py
  resolving a grant to the category and all of its descendants.
- Question create, edit, delete, bulk and the manager summary now accept a
  moderator OR an educator granted the affected categories, and refuse moves
  that would push a question out of the holder's scope. Summary counts are
  scoped to the grant.
- Moderator endpoints to list, add and revoke grants, plus /my-grants driving
  the nav link and the manager's scope banner; grantable-users avoids handing
  moderators the admin-only user list.
- GrantsPanel in the question manager: grant, list and revoke with inline
  confirmation.

Question page:
- The category trail was a fixed 78px band that wrapped into several rows and
  pushed the stem down the page, followed by three more stacked strips. It is
  now one scrollable meta line (breadcrumb + difficulty + type) and a single
  AMBOSS-style action bar (Mark / Listen / Listen through / Clear) between the
  stem and the options. Difficulty is exposed on the runner payload.

Deploy fix: index.html shipped with no cache header, so browsers kept serving
the previous bundle references and a release looked like nothing had changed.
nginx now sends no-cache for HTML and immutable long-cache for hashed assets.

Tests: 16 new backend (recommendation shrinkage, roll-up, locking, grant scope
across create/edit/delete/bulk/summary, moderator gate) and 10 new frontend.
Full suites green: 88 backend, 116 frontend, build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014yhHB8Pc7oQqyqn2Vo9DXA
2026-09-09 18:47:32 +02:00
Daniel
bc9dc290f8 feat: AMBOSS-style session management, question manager, showcase articles
Quiz management area (AMBOSS parity, verified against next.amboss.com):
- GET /quizzes/sessions returns one management row per accessible quiz —
  attempt state, live answered/total from Redis, last score and activity —
  so the page no longer fans out per-quiz requests.
- QuizzesPage rebuilt as a session list grouped by day with a progress bar,
  a state-aware primary action (Start / Resume / Review) and an action menu
  matching AMBOSS: Analysis, Repeat, Rename, Share, Edit, Category, Delete.
  Rename and delete confirm inline; no browser popups.
- Sessions / Library / Categories tabs replace the flat card grid.
- QuizPage honours ?restart=1 so Repeat always begins a fresh attempt.

Question manager (new moderator page at /questions/manage):
- GET /questions/manage/summary counts editorial gaps; /questions/bank gains
  a `needs` filter (category / explanation / difficulty / private) so the
  health tiles double as one-click filters.
- POST /questions/bulk applies category, difficulty, sharing or delete to up
  to 500 checked questions in one call, moderator-only.
- Question edit/create modals extracted to components/QuestionEditors.jsx and
  shared by the question bank and the manager instead of being duplicated.

Showcase articles:
- scripts/seed_showcase_articles.py seeds eight short starter articles across
  the main pediatric systems, each filed under a real category, with stable
  hex section IDs and links to bank questions from the same category.

Mobile: dedicated stylesheets for both pages — rows stack, the action menu
becomes a bottom sheet and the bulk bar docks to the bottom edge.

Tests: 9 new backend tests (session feed states, ordering, Redis-outage
degradation, visibility; bulk actions, gap filters, moderator gate) and 9 new
frontend tests. Full suites green: 72 backend, 106 frontend, build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014yhHB8Pc7oQqyqn2Vo9DXA
2026-09-09 18:27:53 +02:00
Daniel
e8107b5384 fix: question bank filters as AMBOSS-style pop-out drawer
Filters (Exams, Disciplines, Symptoms, Categories, Articles, Saved, Difficulty) open in a scrollable side drawer with close button; the main column stays clean. 97 frontend tests pass.
2026-09-09 14:47:39 +02:00
Daniel
73ef007e0a feat: facet filters, personal libraries, adaptive shortcuts, restart, rename support
Create/bank pages use AMBOSS-style facets: Exams, Disciplines, Symptoms, Systems, Articles, Saved. Personal question libraries with add-to-library in study modal. Adaptive session shortcuts from performance (including weakest topics). Quiz restart with fresh attempt. Category counts computed with two grouped queries. Migration n7a8b9c0d142. 63 backend and 97 frontend tests pass.
2026-09-09 04:31:40 +02:00
Daniel
6fbfedd8a9 fix: AMBOSS-style filter side box on question bank, create-page layout fixes
Bank filters (state, difficulty, categories with educator controls, tags) live in a left side box; main column keeps search and list. Create-page settings/actions grouped into one column with full-contrast primary button. 97 frontend tests pass.
2026-09-09 03:44:39 +02:00
Daniel
236547e646 feat: sample smart-links quiz, bank feedback polish, performance hierarchy
Sample quiz demonstrates option explanations, key points with article links and linked cards. Bank study modal shows per-option explanations and key points. Performance shows main categories with an expandable hierarchy. AMBOSS-style picker polish (chevrons, search box, switch, auto title). Mobile spacing fixes. 97 frontend tests pass.
2026-09-09 03:31:54 +02:00
Daniel
271f0c9054 feat: AMBOSS-style topic picker for custom tests
Systems live in a collapsible side box with search and drill-down subcategories; filters apply live. Mobile collapses the panel. 97 frontend tests pass.
2026-09-09 03:06:24 +02:00
Daniel
91ec6a501c feat: key points smart links, difficulty tags, adaptive sessions, educator-only question management
Key points on questions link into article sections (AMBOSS-style) with samples; difficulty tagging with builder/bank filters; adaptive session algorithm prefers unanswered questions then recycles older incorrect ones, weakest categories first with damping; question create/edit is now admin/educator only; expired exams no longer auto-submit on resume; exam suspend messaging updated. Migrations k4f5a6b7c819, l5a6b7c8d920, m6a7b8c9d031. 63 backend and 97 frontend tests pass.
2026-09-09 02:26:45 +02:00
Daniel
da8a717489 feat: PREP as tags, stats toggle, comments redesign, quizzes CTA
PREP provenance becomes keyword tags tied to the source quizzes; PREP question categories retired. Response statistics get a persistent hide/show toggle. Comments redesigned with avatars, badges and a cleaner compose box. Create Custom Test is now a prominent card on the Quizzes page only. Hierarchy conversion gains pediatric sub-specialties and disease children. 96 frontend tests pass.
2026-09-09 01:30:20 +02:00
Daniel
88912f83c7 feat: system category hierarchy from tags, category editor overlay
Subject tags map to 26 canonical systems via a reversible, backed-up conversion script; questions keep old categories as extra links. Category add/edit now opens a proper dialog instead of stacking above the chips. 95 frontend tests pass.
2026-09-08 23:50:25 +02:00
Daniel
179e51d143 feat: per-option explanations
Questions support an explanation per option, edited in the question dialog and shown in study feedback. Keys must match current options. Migration j3e4f5a6b708. 60 backend and 95 frontend tests pass.
2026-09-08 19:29:21 +02:00
Daniel
a1e9340004 fix: auto-start quizzes in their mode, drop quiz code and verbose stats note
Timed quizzes start as exams and learning quizzes as study without a second mode prompt; reopening resumes automatically. Removed quiz code display from in-progress list and the verbose statistics basis sentence. Lab rows keep logical age order per test. 93 frontend tests pass.
2026-09-08 19:07:27 +02:00
Daniel
f5a084e49a feat: Orthobullets-style lab panel with source deep links and card links
Lab references deep-link to article sections or external sources, show linked cards with study links, and educators can attach cards and article targets. Grouped panel layout. Migration i2d3e4f5a607. 57 backend and 93 frontend tests pass.
2026-09-08 16:20:55 +02:00
Daniel
01337c8c25 feat: performance by category dashboard
Accuracy per category from completed non-expired general-bank answers, counting each question in its primary and additional categories. 56 backend and 90 frontend tests pass.
2026-09-08 16:03:45 +02:00
Daniel
cdb1ab7468 feat: multi-subcategory questions, statistics hardening, seeded lab values
Questions can belong to additional subcategories (junction table, counts, builder/bank filters, edit UI chips); response statistics dedupe duplicate options, match case-insensitively and exclude obsolete answers; AI question classification is command-line only (UI trigger removed); lab reference seed script with cited public pediatric ranges. Migration h1c2d3e4f506. 55 backend and 88 frontend tests pass.
2026-09-08 14:43:22 +02:00
Daniel
0fa8d0a689 feat: shareable quiz links, moderated comments and AI authoring
Quiz share links replace the PIN copy with a public /share/{token} landing page; owners can enable/revoke without showing the full link. Moderated article/question comments with approval flow, bounds and rate limits. Educator AI article drafts/refine and private card generation with Celery job polling. Migrations f2a1c9d4e801 and g4b7e2f5a903. 50 backend and 85 frontend tests pass.
2026-09-07 17:10:08 +02:00
Daniel
6c85f8b4a9 fix: resolve article review findings
Escape raw HTML in article markdown, honor section deep links, filter card link listings by bank visibility and publication status, validate source sections. 44 backend and 77 frontend tests pass.
2026-09-07 16:44:07 +02:00
Daniel
d5269d7772 feat: rename Flashcards to Cards and add card-question linking UI
Orthobullets-style Cards naming, per-card link panel to attach/unlink bank questions, article linked-content counts. New FlashcardsPage test suite; 75 frontend tests pass with build.
2026-09-07 16:32:21 +02:00
Daniel
325f7f400f feat: linked topic articles and card associations
Draft/published article library with stable section IDs, breadcrumbs, link remediation, question and card associations, manual card creation and side-by-side/mobile-drawer reading. Migration e8d4f1a27c93. Verified 42 deployed-image backend tests, 72 frontend tests/build and PostgreSQL migration round-trip.
2026-09-07 15:16:48 +02:00
Daniel
1ce3eec7cb feat: authorize tutor context and protect uploaded media
Tutor questions require owned selected attempts; similarity context filters eligibility before ranking. Uploads move to a permission-aware boundary with reference ACLs, canonical legacy aliases, pre-mutation attachment checks and card-aware moderator rules. Nginx stops caching media and supplies native byte ranges. Verified 37 deployed-image backend tests, 69 frontend tests/build, real pgvector/Nginx/browser checks, and two independent reviews.
2026-09-07 15:04:00 +02:00
Daniel
c630b0270d fix: preserve saved quiz progress across resume failures
Block Start after failed resume, retry saved state, re-read reused-attempt progress, surface cache failures and prevent leaving on failed save. Handle nullable review settings and register lab model with Alembic. Verified 25 deployed-image backend tests, 53 frontend tests/build and PostgreSQL schema round-trip.
2026-09-07 03:34:55 +02:00
Daniel
a3a6ef7995 feat: redesign quiz runner and add study tools
Add Orthobullets-inspired numbered-answer UI, explicit study response confirmation, response statistics, review navigation, safe calculator, keyboard controls and sourced educator lab references. Persist attempt mode to prevent query-flag exam disclosure. Combined deployed-image backend suite (22), frontend suite (48), build and synthetic desktop/mobile browser checks pass. PostgreSQL round-trip and independent review remain release gates; no production deployment.
2026-09-07 03:10:23 +02:00
Daniel
38f3fb8250 fix: remove Turnstile from quiz password login
Remove the challenge from standalone and landing login plus backend verification; retain registration/contact protection and existing password, email verification, SSO and login rate-limit checks. Verified 18 backend tests in deployed image and 17 frontend tests plus build.
2026-09-07 02:54:12 +02:00
Daniel
f696b99569 fix: close custom-test review gaps and verify grading
Handle ownerless question revocation, legacy hide sharing, private deletion, selected-set grading including skips, UI validation/reparent/delete safeguards and offline-safe hierarchy migration. Verified 14 backend tests in deployed image, 13 frontend tests/build and real disposable PostgreSQL migration round-trip. Related tutor/image privacy work remains before deployment.
2026-09-07 02:07:58 +02:00
Daniel
affd7177b5 feat: add category-based custom tests and permission-safe sharing 2026-09-07 01:17:42 +02:00
Daniel
3cf21d4438 chore: preserve pre-revamp local changes
Recovery snapshot of the existing worktree before the Orthobullets-inspired revamp. Includes explanation images, classification snapshots, quiz visibility/resume fixes, quiz codes, TTS options and bot formatting. Secret heuristic and Python syntax checks passed; not a release or full behavioral validation.
2026-09-07 00:50:35 +02:00
Daniel
95de56d81b Improve quiz note and navigation layout
All checks were successful
Mobile Android Release / android-release (push) Successful in 1m33s
2026-05-14 17:30:08 +02:00
Daniel
55713902ed Make MyNote draggable
All checks were successful
Mobile Android Release / android-release (push) Successful in 1m32s
2026-05-12 18:39:50 +02:00
Daniel
9f97218f39 Add persistent MyNote for quizzes
All checks were successful
Mobile Android Release / android-release (push) Successful in 1m32s
2026-05-12 17:51:23 +02:00
Daniel
4b75edef4c Remove highlight toast notifications 2026-05-12 17:02:41 +02:00
Daniel
e2c070b70b Refine quiz image zoom increments 2026-05-12 16:44:11 +02:00
Daniel
dd959371d1 Add dashboard in-progress quizzes 2026-05-12 16:41:44 +02:00
Daniel
d4ef94a117 Remove dashboard document management link 2026-05-12 16:38:33 +02:00
Daniel
17b0f06037 Add quiz image zoom controls 2026-05-12 16:36:53 +02:00
Daniel
ffeb35922a Improve quiz UX and add Telegram bot 2026-05-12 15:57:27 +02:00
Daniel
fdebda993c Improve quiz TTS and Android release build
Some checks failed
Mobile Android Release / android-release (push) Failing after 1s
2026-05-12 01:01:01 +02:00
Daniel
d59c8bed6f Remove coach modes and improve quiz audio 2026-05-10 01:24:06 +02:00
Daniel
67a90f73f5 Landing page: add Bedside calculators bullet + tile to AI Scribe section
Adds a 6th feature bullet (with NEW badge) and a 7th icon tile for the
pediatric calculators + bedside emergency reference that ships with the
Pediatric AI Scribe app. Mentions weight-based dosing, sepsis, status
epilepticus, RSI, burns, anaphylaxis pathways.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 02:59:34 +02:00
Daniel
422fb1c14f Fix uncategorize quiz — omit category_id param when null
Previously sent ?category_id= (empty string) which FastAPI rejects
with 422 validation error when parsing as int.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 21:34:50 +02:00
Daniel
a31388ed17 Revert Settings redesign, keep only abridged documents list
- Restore original single-column 600px layout
- Keep documents abridged to 5 most recent with 'Show all' button

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 20:27:29 +02:00
Daniel
0489bb49a1 Redesign Settings page — responsive grid, abridged docs, less scroll
- Page max-width 1100px (from 600px) for desktop side-by-side layout
- CSS grid auto-fits cards at minmax(320px, 1fr) — 1 col mobile, 2-3 cols desktop
- Cards align to top (alignItems: start) so short ones don't stretch
- Password change hidden behind a <details> summary to reduce scroll
- Notifications + Appearance + Admin compacted (smaller padding, tighter layout)
- Documents section: shows 5 most recent, 'Show all N' button expands inline,
  'Show less' collapses — no separate page needed
- Long filenames truncate with ellipsis
- Removed redundant descriptive text, shortened labels

Mobile: single column stack, preserves all functionality.
Desktop: 2-3 columns depending on viewport width.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 20:24:41 +02:00
Daniel
2a0dd56f95 Add ai_answer extraction + broader ai_decide sampling + flashcard title edit
ai_decide now samples 4 points across the section (start, 1/3, 2/3, end)
instead of just the first 30 + last 20 pages. This gives accurate strategy
detection on large documents where the answer format might be deeper in.

New ai_answer extraction mode:
- Extracts questions from Q&A-format PDFs that have no answer key
- AI picks the correct option from each question's choices
- Generates explanation using document context + medical knowledge
- Useful for PDFs like practice tests where answers were never included
- Available manually and as an ai_decide strategy

Flashcard decks can now be renamed:
- PATCH /flashcards/{deck_id} updates title
- Inline edit on FlashcardsPage with responsive layout (input full-width,
  buttons wrap under it so Cancel never overflows the card)
- Title truncates with ellipsis when not editing

Note: generate mode (textbook -> MCQs) is unchanged per user request.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 20:17:49 +02:00
Daniel
5398342e3d Suspend pauses timer; hide timer-expired attempts from history
Suspend now pauses the timer instead of letting it run out:
- 'Suspend & Leave' sends suspended=true with time_left to backend
- On resume, backend re-anchors started_at to now with held time_left
- Closing tab without suspending continues to run the timer (unchanged)

Timer-expired auto-submits are marked with expired=1 and excluded from:
- Attempt history (GET /attempts/history)
- Dashboard stats (quiz count, total attempts, average score)
- Attempt list (GET /attempts)
- DDL: ALTER TABLE quiz_attempts ADD COLUMN expired INTEGER DEFAULT 0

Course-quiz decoupling is preserved — these changes only touch
non-course quizzes (Quiz.course_id IS NULL).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 19:27:46 +02:00
Daniel
c44014c787 Improve upload progress UX — indeterminate bar and elapsed time
Local uploads appeared stuck at 0% because onUploadProgress
doesn't fire granularly on fast local networks or when network
buffering makes the browser report instant completion.

Changes:
- Animated indeterminate progress bar when percent unavailable
- Elapsed seconds counter so user sees activity
- Separate "Uploading" vs "Processing on server" stages
- Hint message about large PDFs taking time

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 19:17:33 +02:00
Daniel
b32fc65236 Fix mark-complete 403 for course creators previewing lessons
Creators preview courses without enrolling, so markComplete and SCORM
onComplete were hitting the progress endpoint and getting 403. Now
guarded by enrollment check — buttons hidden and callbacks skipped
for non-enrolled users.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 02:25:21 +02:00
Daniel
2db5cb74d7 Fix SCORM content blocked by CSP and X-Frame-Options
- Add 'self' to frame-src in CSP to allow same-origin iframes
- Add 'unsafe-eval' to script-src (SCORM packages often use eval)
- Add /uploads/scorm/ location that strips frame-blocking headers
- SCORM content served without X-Frame-Options or CSP restrictions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 02:08:51 +02:00
Daniel
d44ad883e9 Add SCORM data persistence across sessions
SCORM runtime data (bookmarks, scores, lesson position) now saved to
Redis via GET/PUT /courses/{id}/lessons/{id}/scorm/data endpoints.
Data loads before iframe renders and saves on Commit/Finish/Terminate.
90-day TTL on stored data.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 01:53:53 +02:00
Daniel
84c899a814 Add SCORM API shim for proper content communication
SCORM content expects window.API (1.2) or window.API_1484_11 (2004)
on the parent frame. Without it, content fails to initialize.

Implements all 8 required methods for both SCORM versions:
- Initialize/LMSInitialize, Terminate/LMSFinish
- GetValue/LMSGetValue, SetValue/LMSSetValue
- Commit/LMSCommit, error methods
- Auto-marks lesson complete when content reports completion
- Removed iframe sandbox to allow parent frame API access

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 01:36:44 +02:00
Daniel
db98c6ae69 Add certificate generation, SCORM support, QTI export/import
- Certificate: PDF generated on course completion, download from course page
- SCORM: upload ZIP packages as lesson type, served in iframe, manifest parsed
- QTI 2.1: export selected/all questions as XML, import QTI files into bank
- Uses fpdf2 for certificate PDF generation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 01:30:09 +02:00
Daniel
699cbabfcb Add OIDC/SSO login, reminder fixes, user notification settings
- Add generic OIDC/SSO support (configurable via env vars)
- Admin can enable SSO-only mode (disables password login)
- SSO callback auto-creates and verifies users
- Login page shows SSO button when configured, hides password form in SSO-only mode
- Fix reminders: skip course quizzes and deleted quizzes
- Don't create reminders for course quiz attempts
- Add user reminder opt-out toggle in Settings > Notifications
- Scheduler checks user opt-out before sending emails

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 04:10:15 +02:00
Daniel
695392f022 Remove HIBP breached password warnings
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 02:24:24 +02:00
Daniel
affd2fc116 Add admin guide, document CLI commands, consolidate account into settings
- Add list-docs, fix-stuck-docs, reprocess-doc CLI commands
- Create ADMIN.md with deployment, CLI, database, and troubleshooting docs
- Add HIBP warning to Settings page password change
- Remove separate Account nav link (already in Settings)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 20:39:58 +02:00
Daniel
8137d5b20c Decouple course quizzes, replace passlib, add security hardening
- Fully decouple course quizzes from main quiz system (hidden from
  dashboard stats, history, search, results page)
- Course quiz results show "Back to Course" instead of retake/delete
- Add allow_review toggle for course creators to control answer review
- Show quiz title on course page, hide pool size from students
- Add course thumbnails to browse cards
- Replace passlib with bcrypt directly (compatible with existing hashes)
- Add HIBP breached password warnings on register/reset/change password
- Add CLI management tools (reset-password, set-role, stats, etc.)
- Fix quiz PATCH endpoint: ownership check instead of moderator-only
- Add max_length validation on course/module/lesson titles
- Fix score display bug on results page (0 of N when review disabled)
- Fix question count on course quiz start (show per-attempt, not pool)
- Improve suspend warning for timed course quizzes with max attempts
- Clean up validation error messages (show "Invalid email" not Pydantic dump)
- Add DDL migration for allow_review column

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 21:31:05 +02:00