Commit graph

165 commits

Author SHA1 Message Date
Daniel
2d2845fa0a fix: legible section bar, and a real page for editing a question
Colours
`.sr-only` was used in four places and never defined, so every label meant for a
screen reader was printed on the page: "Studying for" beside the exam switcher,
"Difficulty", "Questions", "Category for …". On the old dark bar the stray text
passed for a label; on the new light one it was grey-on-grey and looked broken.
Defined it, and restyled the exam switcher for the surface it actually sits on
now rather than for the dark bar it used to.

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

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

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

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

198 frontend tests green.

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

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

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

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

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

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

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

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

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

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

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

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

166 backend, 188 frontend green.

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

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

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

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

177 frontend tests green.

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

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

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

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

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

154 backend, 163 frontend green.

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

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

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

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

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

146 backend, 152 frontend green.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Tests: 136 frontend green, build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017acfNLsJpnkvH3sCZSjMJM
2026-09-10 10:34:58 +02:00
Daniel
d071e5cdc5 feat: serve uploads through storage, and move all 3,852 files to MinIO
Serving went straight to disk with FileResponse, so object storage was
effectively write-only: bytes went to the bucket and were still read from the
volume. `/uploads/{path}` now tries the local file first, then the object,
keeping the existing authorisation and path-confinement checks in front of both.
That is what makes the volume removable at all.

Migration (scripts/migrate_uploads_to_s3.py)
Every file is copied and read back with a SHA-256 comparison before anything is
deleted, and deletion is a separate opt-in flag that refuses to run if a single
file failed to verify. 3,852 files, 853.7 MB, all verified, then removed from the
volume — which now holds 0 files.

A bug this caught in its own first run: verification used `storage_service.load`,
which falls back to the volume, so it compared each local file against itself and
reported 3,852 perfect matches against an empty bucket. `s3_object` reads
strictly from S3 with no fallback, and verification uses that. The fallback is
right for serving and wrong for verifying, and the two now have separate calls.

Proven before deleting: a file removed from the volume still served correctly and
byte-identically from the bucket.

Backups, corrected: borgmatic already covers /var/lib/docker/volumes, so
quiz_minio_data is backed up nightly with 7/4/6 retention — my earlier claim that
MinIO was outside the backup routine was wrong, based on db-backup alone.
Existing archives still hold the old uploads volume, so there is no window in
which these files exist in only one place.

Tests: 131 backend green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017acfNLsJpnkvH3sCZSjMJM
2026-09-10 10:23:34 +02:00
Daniel
db2df87fc6 feat: MinIO-backed media libraries, and file the last 316 questions
Storage
Media now goes through `storage_service`, which has two backends: the container
volume, and S3/MinIO. A volume can only be mounted by one host, has no presigned
URLs and no lifecycle rules, none of which suits ~860 MB of media. Reads fall
back to the volume when an object is missing, so the existing uploads keep
working and files can migrate gradually rather than in one risky pass.

A row stores the object key, never a URL: a URL embeds the backend, so a row
holding `http://minio:9000/...` breaks the moment the backend changes.

MinIO publishes no host ports — the backend reaches it over the compose network,
and 9000/9001 are already taken on this host by other stacks.

Image libraries (migration d2e3f4a5b6c7)
An image belongs to a library, and a person is granted a library the way they are
granted a category, so access can be given to some images without giving away all
of them. Tags reuse the shared `question_tags` vocabulary rather than inventing a
media-only one. Uploads are type- and size-checked, stored through the service,
and embedded so an image can be found by what it shows.

Classification finished
The 316 questions the chooser had declined are now filed with `--force`, which
takes the nearest candidate from the same shortlist the chooser saw. 306 were
forced, 10 the chooser accepted on this pass. No question sits on a bare system
any more:

  system only          2,730 -> 0
  condition/subsystem    214 -> 1,782
  full depth               4 -> 1,166

A forced match is a weaker signal than a chosen one, so expect more errors among
those 306 — but the original system stays as a cross-link, so nothing is lost and
they can be corrected by hand.

Tests: 8 new backend covering library scoping, edit confinement, shared-vocabulary
tags, storage indirection on upload, and type/size limits. 131 backend green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WgRcMaScVEL7TBLpnAoSV9
2026-09-10 06:45:22 +02:00
Daniel
3b1ac9aea8 feat: classify the questions left sitting on a bare system
1,486 questions had a top-level system as their primary category with no
condition to promote — General Pediatrics alone held 396 and has no
subcategories at all, so its questions are almost always about something that
already exists elsewhere in the tree.

Two stages, same discipline as the AI-mode citation contract: retrieval ranks
existing categories against the question's stored embedding and keeps the nearest
eight; a chooser picks one of those or says NONE. The model never sees the whole
tree, never writes a category name, and anything not on the shortlist is
discarded — so a category it invents cannot survive. Nothing new is created:
questions are filed into categories an educator already has.

1,170 filed, 301 declined by the chooser, 15 with no candidate above the
similarity floor. Filing depth across the bank:

  system only          2,730 -> 316
  condition/subsystem    214 -> 1,596
  full depth               4 -> 1,036

A spot check found roughly three in four correct — a question about an ADHD
medication refill was filed under Obesity. That is acceptable only because the
original system is kept as a cross-link on every moved question, so a wrong pick
narrows nothing and never loses a question. Recorded in the TODO for an
editorial pass.

Applied after a backup. Tests: 123 backend green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01365DYKu14YtsBKv2ycW6eG
2026-09-10 05:17:24 +02:00
Daniel
5b865faf80 feat: promote primary categories from system down to condition
93% of questions had a top-level system as their primary category, so the
condition tree was reached almost entirely through cross-links: every deep node's
count was a link count, and a question about Tetralogy of Fallot read as
"Cardiology" rather than as what it is about.

Where a question already linked to a condition inside its own system, that link
was the specific classification, so it becomes the primary and the system is kept
as a cross-link — nothing narrows. 1,244 promoted, leaving 1,486 at system level
rather than 2,730. Destinations check out: VSD under Congenital Heart Disease
under Cardiology, Pertussis under Bacterial Infections under Infectious Disease.

Where several candidates existed the deepest won, breaking a tie on question
count; 31 that tie on both are left alone rather than resolved arbitrarily.

1,455 questions have no condition inside their own system at all, so there is
nothing to promote — they are genuinely unclassified below the system, worst in
General Pediatrics (396, which has no subcategories at all), Emergency Medicine
and Adolescent Medicine. That needs classification work, not a data move, and is
recorded in the TODO rather than guessed at.

The one uncategorised question was the seeded febrile-seizure demo; it is filed
now, so all 2,948 resolve to a system.

Idempotent, applied after a backup. Tests: 123 backend green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01365DYKu14YtsBKv2ycW6eG
2026-09-10 04:32:55 +02:00
Daniel
3cbb1957cf feat: nest the system tree and give each condition one home
Nesting (scripts/nest_systems.py)
The tree was 27 systems and ~1,050 conditions, one level deep — a flat
alphabetical wall in the Systems picker. It is now System → Subsystem →
Condition: 69 subsystems, 305 conditions nested, 564 left directly under their
system because forcing an approximate bucket is worse than leaving them.
Subsystem names are standard textbook divisions, each matched by an explicit
pattern so a dry run shows exactly what moves. Three false positives the dry run
caught were fixed before writing: "erythema" was pulling Systemic Lupus
Erythematosus into dermatology, and a bare "meconium"/"aspiration" was filing
Meconium Ileus as respiratory and Meconium Aspiration as foreign-body.

One home per condition (scripts/merge_duplicate_conditions.py)
The tag→category conversion made each question's subject tag a parent, so a
question tagged Pulmonology and Emergency Medicine produced an "Asthma" row under
each. 330 of 618 condition names lived in several systems — Sepsis in seven,
Failure To Thrive in eight — so no filter ever added them up.

216 names are now folded into the row holding the most questions, and the other
systems survive as `question_category_links` on each moved question: a link is
how "this matters to nephrology *and* emergency medicine" is meant to be said,
duplicate rows are not. 347 rows removed, 1075 → 786, all 2,948 questions intact.

114 names are an even split across systems. Those are reported, not merged —
picking a home for Pneumonia between Pulmonology and Infectious Disease is a
medical call, and an arbitrary tie-break would bury it.

Both scripts are idempotent and were applied after table backups.

Tests: 123 backend green.

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

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

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

Tests: 136 frontend green, build clean.

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

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

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

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

Tests: 136 frontend green, build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01365DYKu14YtsBKv2ycW6eG
2026-09-10 04:04:28 +02:00
Daniel
e0dc3a5da5 docs: capture the UI feedback from 2026-09-10
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01365DYKu14YtsBKv2ycW6eG
2026-09-10 04:02:50 +02:00
Daniel
2958779067 feat: grants can name an exam and a discipline, not only a category
An admin can now say "you edit Step 1 Cardiology" rather than only "you edit this
category". Each dimension on a grant is nullable and means "any"; a grant covers
the questions matching all the dimensions it sets, and holding several grants is
the union of their coverage (migration c1d2e3f4a5b6). A check constraint refuses
a grant that names nothing, which would otherwise mean "everything".

Permission checks now run against a predicate over Question rather than a set of
category ids, so the exam and discipline dimensions actually take effect on edit,
delete and bulk actions instead of being silently ignored.

Pediatrics is unbound back to a global tag. With counts already scoped by the
learner's active exam, one global row gives the right number per exam, so
scoping the row bought nothing and duplicating a 6,740-tag vocabulary per exam
would have to be repeated for every rename and merge.

Tests: 123 backend green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01365DYKu14YtsBKv2ycW6eG
2026-09-10 04:02:36 +02:00
Daniel
0b58b903d2 docs: mark version history and exam-scoped tags done; settle the link design
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpfzbZ1QTLMeVYxM2kyq8m
2026-09-10 02:48:52 +02:00
Daniel
b7947b1d53 feat: exam-scoped disciplines and exam-scoped facet counts
A discipline may now belong to one exam. `question_tags.exam_id` NULL keeps a tag
shared — Cardiology means the same thing whichever exam you sit — while a set
exam_id scopes it. Boards Pediatrics and a future Step 1 Pediatrics are therefore
separate rows over genuinely different bodies of content, not one label stretched
across both. Uniqueness moves from (name, type) to (name, type, exam) to allow it
(migration b0c1d2e3f4a5).

`scripts/bind_exam_tags.py` binds Pediatrics to Pediatrics Boards and tags the
884 questions in that exam that were missing it — the whole bank is paediatrics,
so it now reads 2,948.

Facet counts are computed within the learner's active exam, and a tag scoped to a
different exam is left out: an unscoped list offered disciplines that could not
match anything they were studying. With no exam chosen, everything is offered as
before.

Tests: 4 new backend (same name once per exam, unscoped list offers all, choosing
an exam scopes counts and hides other exams' tags, switching exam switches which
Pediatrics is offered). Full suite green: 123 backend.

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpfzbZ1QTLMeVYxM2kyq8m
2026-09-10 02:27:44 +02:00
Daniel
8613fd1e5e feat: PREP study plans of numbered blocks
The PREP sets were loose admin-generated quizzes. They are now study plans: one
per year, split into blocks of 50 numbered "Block 1", "Block 2", plus a
"PREP Mixed" plan of 300 drawn at random across every year. 12 plans, 2,821
questions, applied to production.

Block membership is snapshotted rather than stored as a filter — a plan you are
part-way through must not reshuffle between visits. Re-running the seeder updates
years whose questions changed and leaves the mixed draw alone unless --reshuffle.

Starting a block reuses the learner's existing quiz for it; without that,
reopening a block would create a duplicate test each time and scatter the
attempts across them. Only questions the learner may see are included.

Tests: 113 backend green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpfzbZ1QTLMeVYxM2kyq8m
2026-09-10 02:25:29 +02:00
Daniel
899ad5e879 feat: media as a searchable corpus, ready for a vision embedder
Images were findable only by the filename someone typed. `media_assets` gives
them a title, caption, alt text, a category on the shared tree and tags, with a
weighted tsvector so they are searchable now (migration y7e8f9a0b1c2).

The embedding column is filled from the caption today. A vision-capable model can
fill it from the image itself later without another migration — and because
`embedding_model` stamps every vector, a text-embedded caption and a
vision-embedded image stay distinguishable instead of being silently mixed in one
index. Adding "media" to the embeddable kinds is all the retry task, the full
regeneration and the health report needed.

`media_tag_links.tag_id` carries no ORM-level foreign key: `question_tags` is
created by raw DDL rather than a model, so the constraint lives in the migration
where the table actually exists.

Tests: 113 backend green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpfzbZ1QTLMeVYxM2kyq8m
2026-09-10 02:22:52 +02:00
Daniel
4d0cdc8f2f feat: section-level index so retrieval can cite a section, not a whole article
An article embedded as a single vector, which finds the article but not the
paragraph — so a citation could only ever point at the top of a page. Sections
live in a JSON column and cannot carry a vector or a full-text index, so they are
now projected into `article_section_index`: one row per section with its own
embedding and weighted tsvector (migration x6d7e8f9a0b1).

- Rows are keyed by section id, so editing a section updates it, removing one
  deletes it, and an unchanged section is not re-embedded on every save.
- `article_section` joins the embeddable kinds, so the retry task, the full
  regeneration and the health report cover it without further changes.
- `hybrid_ids(db, query, "article_section")` searches it like any other corpus.

This is the groundwork for grouped search results (article, then the sections
that matched) and for AI citations that deep-link to the right section.

Tests: 113 backend green.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Tests: 101 backend green.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014yhHB8Pc7oQqyqn2Vo9DXA
2026-09-09 18:27:53 +02:00
Daniel
e8107b5384 fix: question bank filters as AMBOSS-style pop-out drawer
Filters (Exams, Disciplines, Symptoms, Categories, Articles, Saved, Difficulty) open in a scrollable side drawer with close button; the main column stays clean. 97 frontend tests pass.
2026-09-09 14:47:39 +02:00
Daniel
73ef007e0a feat: facet filters, personal libraries, adaptive shortcuts, restart, rename support
Create/bank pages use AMBOSS-style facets: Exams, Disciplines, Symptoms, Systems, Articles, Saved. Personal question libraries with add-to-library in study modal. Adaptive session shortcuts from performance (including weakest topics). Quiz restart with fresh attempt. Category counts computed with two grouped queries. Migration n7a8b9c0d142. 63 backend and 97 frontend tests pass.
2026-09-09 04:31:40 +02:00
Daniel
6fbfedd8a9 fix: AMBOSS-style filter side box on question bank, create-page layout fixes
Bank filters (state, difficulty, categories with educator controls, tags) live in a left side box; main column keeps search and list. Create-page settings/actions grouped into one column with full-contrast primary button. 97 frontend tests pass.
2026-09-09 03:44:39 +02:00
Daniel
236547e646 feat: sample smart-links quiz, bank feedback polish, performance hierarchy
Sample quiz demonstrates option explanations, key points with article links and linked cards. Bank study modal shows per-option explanations and key points. Performance shows main categories with an expandable hierarchy. AMBOSS-style picker polish (chevrons, search box, switch, auto title). Mobile spacing fixes. 97 frontend tests pass.
2026-09-09 03:31:54 +02:00
Daniel
271f0c9054 feat: AMBOSS-style topic picker for custom tests
Systems live in a collapsible side box with search and drill-down subcategories; filters apply live. Mobile collapses the panel. 97 frontend tests pass.
2026-09-09 03:06:24 +02:00
Daniel
91ec6a501c feat: key points smart links, difficulty tags, adaptive sessions, educator-only question management
Key points on questions link into article sections (AMBOSS-style) with samples; difficulty tagging with builder/bank filters; adaptive session algorithm prefers unanswered questions then recycles older incorrect ones, weakest categories first with damping; question create/edit is now admin/educator only; expired exams no longer auto-submit on resume; exam suspend messaging updated. Migrations k4f5a6b7c819, l5a6b7c8d920, m6a7b8c9d031. 63 backend and 97 frontend tests pass.
2026-09-09 02:26:45 +02:00
Daniel
da8a717489 feat: PREP as tags, stats toggle, comments redesign, quizzes CTA
PREP provenance becomes keyword tags tied to the source quizzes; PREP question categories retired. Response statistics get a persistent hide/show toggle. Comments redesigned with avatars, badges and a cleaner compose box. Create Custom Test is now a prominent card on the Quizzes page only. Hierarchy conversion gains pediatric sub-specialties and disease children. 96 frontend tests pass.
2026-09-09 01:30:20 +02:00
Daniel
88912f83c7 feat: system category hierarchy from tags, category editor overlay
Subject tags map to 26 canonical systems via a reversible, backed-up conversion script; questions keep old categories as extra links. Category add/edit now opens a proper dialog instead of stacking above the chips. 95 frontend tests pass.
2026-09-08 23:50:25 +02:00
Daniel
ff1aee6fad fix: resolve latest review findings
Category quiz creation counts extra-linked questions; primary category is validated on edit; statistics dedupe collapses case variants. 60 backend tests pass.
2026-09-08 19:53:25 +02:00