Commit graph

241 commits

Author SHA1 Message Date
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
c9abc4f310 feat: extracted questions are staged, and only acceptance takes an id
Extraction wrote straight into `questions`, so a machine's first attempt
took a permanent id the moment it was produced. Ids come from a sequence
and are never reissued: every rejected draft burned one, and every draft
that needed fixing was sitting in the bank while it was being fixed.

A run now lands in a batch of drafts with their own table and their own
sequence. They are read, corrected and decided there, and `accept` is
the only place a Question is created — a copy rather than a translation,
because every field a draft holds is a field a question has, so nothing
is lost at the moment of acceptance.

Accepting is all or nothing, and everything is checked before anything
is created: a call that reports failure must not leave questions behind
from the drafts it got through first. My own test caught that — the
first question existed before the second draft was refused.

Readiness is reported for every draft rather than only on the attempt to
accept it, so a reviewer sees what needs work before opening anything.
A decided draft keeps its row and records what it became, so a batch
reads as a history of what was decided rather than emptying as it is
worked through. An acceptance cannot be undone from here: the question
exists, and deciding twice would make a second one.

No embeddings for drafts. A vector is for finding a question in the
bank, and a draft is not in the bank.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 22:57:08 +02:00
Daniel
0fa5598aad feat: study plans built to the board's content outline
Two shapes, because a plan is asked to do two different things. Papers
are rehearsal: each block is drawn to the ABP's published weights, so
sitting one says something about how you would do on the day. Domains
are study: the board's twenty-four content areas in its own order and
carrying its own titles, each given the share of the plan the board
gives it on the exam.

Both were written, then run against the real bank, which found two bugs
a unit test on a clean fixture would not have. Domains 19 and 20 —
nephrology and genitourinary — both map to our "Nephrology & Urology",
so a question sat in two pools and was dealt twice; the deal now keeps a
record of what has gone. And chunking every question a domain has into
blocks of forty gave preventive care six blocks and the plan a hundred
and sixty, which is not a plan: blocks are shared out by weight, with at
least one per domain so nothing the board examines is left out.

Built on the live bank alongside what was already there: Boards: Full
Papers (12 × 40) and Boards: By Content Domain (27 blocks, 1069
questions). Nothing existing was touched.

Psychosocial Issues and Child Abuse and Neglect — 6% of the paper
between them — had no category of ours at all, so they could contribute
nothing. Both now exist, with sub-topics named from the board's own
subdomains, and all 24 domains map to categories.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 21:40:17 +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
0d4042169a feat(bot): the Telegram bot follows the current taxonomy
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
2026-09-11 19:55:31 +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
4f05b40a6a docs: the evening's queue, written down rather than carried
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 18:51:17 +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
2b6af14b84 chore: every clinical view now reads in the order a decision is made
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
2026-09-11 14:16:07 +02:00
Daniel
942027c502 fix: 'No therapy is required' is an answer, not a placeholder
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
2026-09-11 13:58:02 +02:00
Daniel
d78a8b7ee8 chore: mdm_pass check reports sections that say nothing
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
2026-09-11 13:57:39 +02:00
Daniel
9b7ab72038 fix: a refusal that said nothing had changed
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
2026-09-11 13:47:39 +02:00
Daniel
e89e9f6f70 chore: merge the 99 duplicated conditions that had a clear home
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
2026-09-11 13:37:35 +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
a9969d8d90 fix: my own gate was forcing sections to open on "4."
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
2026-09-11 13:33:27 +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
efa0af2244 fix: the imperative detector only ever saw sentence starts
'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
2026-09-11 13:16:05 +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
db2bbf0638 feat: mdm_pass — give the clinical view the shape of a decision
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
2026-09-11 13:11:30 +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
85ccdadc9b docs: TODO after the overnight run
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 13:00:19 +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
950c591ec5 docs: how the recommendations and adaptive session work, plus two fixes
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
2026-09-11 12:37:25 +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
9bc202d967 feat: prose_pass — move article voice to third person, with a gate that refuses fact drift
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
2026-09-11 05:14:56 +02:00
Daniel
595b619515 feat: cross-links follow a rule instead of linking every mention
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
2026-09-11 05:10:24 +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
3443d4a78a docs: TODO reflects today's sessions/analysis work
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 04:34:09 +02:00
Daniel
3798103b3f docs: open items from the 11 Sep recordings
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 04:31:42 +02:00