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
It was still browsing the flat category list and the free-text keyword
tags, neither of which is how questions are arranged any more. A
discipline holding six hundred questions three levels down showed as
"(0)" because only what was filed directly on the node was counted.
/topics now walks the tree — discipline, sub-topic, condition — with
each node counted over its whole subtree, and any node can be quizzed
whole. /objective scopes every quiz to one exam, listing only objectives
that actually have questions. The keyword browse is gone with the tags.
Chat is where you answer, not where you read the teaching: explanations
are abridged to about a screenful, cut at a sentence, with markdown and
cross-reference markers stripped and a link to the site when there is
more. Per-option reasoning and figures are not sent — the bot never did,
and now says where they are.
Still read-only, and now says so: no account, no history, and a quiz
that lives only until it ends or /stop. `is_shared` remains the only
gate between an anonymous stranger and the bank, applied in one place.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
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
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
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
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
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
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
`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
`[[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
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
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
323 of 323. It was 413 free-form sections under 242 different titles,
most of them one block called "Management" holding everything from the
presenting complaint to discharge advice. They now read Clinical paths →
Diagnosis → Management, with Prognosis and outcome where the source
actually said something about course — 95 articles earned one, the rest
were left without rather than padded.
Six batches, and the gate earned its keep: it caught a dropped
`[[206|epilepsy]]`, a duplicated "24", a repeated sentence that doubled
two numbers, and several numbered lists flattened into bullets. Two
articles were refused on a first import and passed on a second — I had
imported a file its agent was still correcting.
Then the linker re-run over the moved text: 2,150 links, 0 broken, 0
nested, 0 escaped. Instructions to the reader across the whole corpus
are down to 21 from 819 + 71.
Backend 242/242.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
The first version of the check flagged it alongside 'No protocols are
available'. The difference is whether the sentence is about the medicine
or about the source. Two false positives down to none; the two real ones
are both Pediatric Dentistry, whose source was a disclaimer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
A section that promises an answer and then says 'No presentation details
are available' is worse than a missing one. Reported rather than
refused: a one-line prognosis is often the whole honest answer, so the
length alone cannot decide it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
Comparing marker and number *sets* reported 'lost -, added -' whenever a
token merely appeared a different number of times — printed on the line
explaining why the article was refused, which read as a contradiction.
It counts repeats now: 'cross-references changed (duplicated 396x1)',
which is what actually happened to Cystic Fibrosis.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
Correcting the note this was tracked under: "114 tied conditions need a
human call" counted duplicated *names*, not ties. 99 of them had a clear
plurality and were merged; 15 are genuine even splits.
Same script and same safety property as the earlier pass — every moved
question gains a question_category_links row to the system folded away,
so filtering by that system still finds it. 786 categories to 662, 124
rows removed. Checked after: 0 questions with no category at all, 0
links pointing at a deleted row, and Pneumonia's questions still reach
Pulmonology and Allergy/Immunology.
The remaining 15 are written up in docs/tied-conditions.md as a decision
rather than left as a task: what each is tied between, and the note that
the choice only sets where a condition lives, since the other systems
survive as cross-links either way.
Backend 242/242.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
'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
The clinical view of an article was often one numbered list running from
the presenting complaint to discharge. Splitting it into Clinical paths
/ Diagnosis / Management necessarily divides that list — and because the
validator required every numeric token to survive, the restructure kept
the original numbering. Sections opened on "2." and "4.".
The "3." of a list item is a position, not a fact. It is stripped before
numbers are compared, so a restructure is free to renumber; and import
renumbers every section's lists from 1 regardless, per indentation
level, so a nested list counts independently of its parent. A blank line
between items is a loose list, not a new one, and does not reset it.
25 sections across 16 of the 54 already applied were repaired in place.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
"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
`[[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
'When delay is suspected, obtain a detailed history' is as much an
instruction to the reader as 'Obtain a detailed history', and the first
pass could not see it — so the 819 I reported cleared was the count of
one kind. 71 more were buried mid-sentence, after a comma or a
conjunction. The detector now finds both, and export can be limited to
particular variants so a pass does not collide with one already running
over another view.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
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
The Short and Long views have a structure; the Clinical view has none.
413 sections across 323 articles carry 242 different titles, most of
them one free-form block called 'Management' holding everything from the
presenting complaint to discharge advice.
The shape is medical decision making: Clinical paths, Diagnosis,
Management, and Prognosis and outcome where it adds something.
This pass moves text between sections, so nothing can be checked section
by section the way the prose pass was. Every check is over the whole
article's clinical view at once — the same cross-references, the same
numbers, the same overall length — plus the shape itself: known titles,
in order, none missing, none twice. Content may be reordered and resplit
freely; it may not appear or vanish.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
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
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
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
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
Answers the two questions owed: the readiness shrinkage, the three
groupings, the priority ranking, and the four steps of adaptive
selection — including where it is weaker than it looks.
Writing it up surfaced two defects, both fixed here:
* adaptive_select took the first 2,000 candidate rows. The bank is
2,948, so about a third of it could never be selected, and which
third depended on database order. The cap is gone; two integer
columns per question is not a size worth protecting against.
* category lookup was a linear scan through every candidate for every
recorded answer — O(answers x candidates), the slowest part of
building a session. It is a dict now.
Left alone and documented instead, because changing them changes which
questions a learner is given and that is not a silent decision: adaptive
ordering uses raw category accuracy rather than the shrunk readiness the
recommendations page uses, and difficulty is a filter rather than
something the session moves along.
Backend 231/231.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
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
348 sections carry 819 imperatives ('Obtain a CBC', 'Counsel parents').
Reference prose states what is done. This script exports those sections,
takes rewrites back, and refuses any that lost a cross-reference, changed
a number, or landed outside 0.7-1.4x length — so a voice pass cannot
quietly become a content pass.
Measured while building it: first person is zero in the corpus. An
earlier count of 136 was matching 'US' the country as the pronoun 'us'.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
3,560 links became 2,191, and the distribution is the point: Seizures
was linked 119 times and is now 48, across the 50 articles that mention
it — about one per view, which is what a first mention means.
1. First mention per view, not per section. Short, Long and Clinical
are read separately so each earns one; nine sections did not earn
nine. Metabolic Acidosis: 39 links to 19.
2. Lists are jump lists, prose is not. A differential or causes list
keeps a link on every distinct condition — that is the one place a
reader wants ten in a row.
3. The 23 titles mentioned in more than 5% of articles link from lists
only. "Seizures may occur" is not a topic anyone breaks off reading
to visit; it is the vocabulary of paediatrics.
4. A finished marker is now stashed, so a shorter title cannot re-cut
one already made.
6. A link whose label is not the target's title was written by hand
and is never touched, which is what makes --apply re-runnable: it
strips only its own work and reapplies the rule.
Correcting myself: I first measured hub terms on text that was already
linked, where the word pattern cannot see a mention wrapped in a marker.
That undercounted by four times and made the corpus look far less
repetitive than it is. The threshold is measured on clean prose.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
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
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
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
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
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
/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
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
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
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
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
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
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
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
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