This site uses one functional cookie to keep feature rollouts consistent for you. Nothing is set until you choose. See the privacy notice.
Dev notes
A vascular surgery resident needs a research project, and the hard part isn't generating ideas. It's knowing which idea is actually unclaimed. "Has anyone studied this?" is a question you can only answer by searching, and searching twenty candidate topics by hand is an afternoon you spend before you've learned anything. So I built the afternoon into a page: every candidate topic gets scored against how much literature already exists, the papers behind that score are one click away, and the populations those papers enrolled are charted so the gaps show themselves.
The page is built around one workflow: narrow from a field to a question you can actually answer with the patients you have access to.
The shape of a good answer coming out of this: a specific question, in a specific population, that the literature has left thin, with three or four existing papers whose methods you can adapt.
Two databases, both through public APIs. I looked at scraping journal sites and it's the wrong trade: NCBI's E-utilities is free, needs no key, indexes essentially every medical journal, and returns structured records with MeSH terms already applied. A scraper would be more code, more fragile, legally murkier, and worse data. Europe PMC joins it as a second source for the coverage scraping would otherwise have been for — preprints and records PubMed doesn't carry.
Every publication list names the databases it searched, and the two sources are deliberately not equal partners: evidence levels are computed from PubMed alone. Mixing indexes into one denominator would make the none/sparse/active numbers mean something different depending on which sources answered, which is worse than a slightly narrower count that always means the same thing.
The API is the easy half. What makes the page useful is a hand-written data file: about two dozen candidate topics, each with its own PubMed query in PubMed's own syntax, plus the journals and the demographic clauses. Keeping the queries as data rather than generating them means every number on the page is traceable to a search you can paste into PubMed yourself and verify.
I originally planned to leave it there, on the grounds that auto-derived topics would be noisy term soup rather than research questions. Building both changed my mind about the framing rather than the criticism: derived headings genuinely are worse as questions, but they're better as signal. The curated list can only contain gaps I already suspected, and a tool whose whole purpose is finding what nobody has looked at shouldn't be bounded by what one person thought of. So the two tabs do different jobs — curated topics are questions you can act on today, discovered headings are where to look next — and keeping them separate is what makes the noisier half tolerable.
The discovered path is also the one place a search fragment originates upstream instead of from my file, so it gets validated as a MeSH descriptor before it can become part of a query — words, digits, and the comma-and-hyphen punctuation NLM actually uses. Anything else is rejected rather than escaped, since a descriptor never needs quotes or brackets and the only thing that would want them is an injected clause.
I wrote the plan before the code and reviewed it before building, which caught both of the things I'd have otherwise shipped wrong. Curated-only topics was one, covered above. The other was calling PubMed sufficient on its own — true for counting, false for reading, because preprints are exactly where an unclaimed topic shows early activity and PubMed is the last place they appear. Both changes cost more in the plan than they would have cost to skip, and far less than discovering them after the fact.
Update — August 8, 2026
Finding a paper and being ready to discuss it are different problems. A citation on its own gives a trainee nothing to prepare with, and the hour before a journal club is spent working out what is actually arguable about the study rather than arguing about it. So there is now a Journal club tab: papers from the last two years on a chosen topic, each arriving with at least three points to raise and three questions to put to the room.
The prompts had to be grounded or not exist. The easy version writes three questions that fit any paper — what was the design, what were the limitations, does it change practice — and that is worse than no prompts at all, because it looks like preparation and teaches nothing. There is no LLM in this app to do better, so the substance comes from two things Europe PMC returns as structured JSON: NLM publication types, which name the design authoritatively rather than leaving it to be guessed from prose, and abstracts marked up with <h4>Methods</h4> headings.
That is enough to be specific. A retrospective cohort gets told that confounding by indication is the standing threat; a meta-analysis gets told it inherits every bias of the studies pooled into it; a case report gets told it can prove something is possible and never how often. The results sentence is quoted back with a nudge to check whether the effect is absolute or relative, and the paper's own conclusion is quoted into a question — what would have to be true of these patients for that to hold for yours? Sample size is extracted when the methods state one, and deliberately not guessed when they don't: a wrong number in a discussion prompt is worse than no number, so anything shaped like a year is rejected outright.
Two things I chose to leave imperfect. Papers without an abstract are dropped rather than given generic prompts — the floor of three points exists to guarantee usefulness, not to be filled with filler, and the fallbacks that do exist still name this paper's journal, year or title. And the sample-size extractor misses abstracts that spell numbers out ("Two thousand thirty-nine patients"), which I found in live output and left alone: catching it means parsing written numerals, and the failure mode of missing a number is a quieter prompt, while the failure mode of parsing it wrong is a false one.
Update — August 7, 2026
The evidence badges answer "is this crowded?" but they round a number into a word, and the question I actually kept asking was numeric: how many papers, on this topic, in the last five years, about these patients. So there is now a Counts tab that is just that — every topic with its five-year count, sortable fewest-first so the thin ones rise to the top.
Picking a population adds two things to each row: the count within it, and the share of that topic's own total. The share is the part that earns its place. A raw filtered count can't distinguish a small topic from an underrepresented population — four papers is a lot for a niche question and nothing for a busy one. Reading 0 of 15 · 0% down a sorted column is the fastest way I've found to see where a group simply hasn't been studied. It costs no extra request either: the unscoped and scoped scans are both already in the query cache, so the share is a division.
What I deliberately did not build. The obvious version of this is the full matrix — every topic against every population, precomputed. That is 384 upstream queries, roughly two minutes of paced calls, and the first visitor would wait all of it for a grid they would read four cells of. The split is fetched per topic, on expand, over the same five-year window as the column it came from. An all-time split sitting beside a five-year total would be two different questions sharing a row.
Filters that lead nowhere are now disabled rather than offered: once a population is selected, the remaining ones are counted on top of it and any combination that would return zero papers is greyed out with its count. Offering a filter that leads to an empty list wastes exactly the time this tool exists to save.
Update — August 7, 2026
"824 papers" means nothing without knowing whether that is one year or forty, and the page was quietly leaving that to inference. The routes now return the window they actually counted, so Counts reads Papers published 2021–2026 with real years rather than a vague "last five years", and the all-time columns say plainly that they cover every year PubMed indexes. Publication lists name the oldest paper on screen — they are capped at the twenty newest, so that is the floor of what you are looking at, not the floor of what matched.
The demographic scans also say when they are still running. Sixteen paced upstream counts take about twenty seconds, and until this change the bars rendered at zero width in the meantime. On a page whose entire purpose is that a zero is a real finding, a scan in progress that looks identical to "nobody has studied this" is the worst possible failure. Now it says so, in words, and withholds the numbers until they are real.
Each population on the Demographics tab is also clickable now, opening the papers for that group as its own section underneath — scoped to the open topic when there is one, or the whole field when there isn't. Seeing that a group has 80 papers and being unable to read any of them was a dead end.
Update — August 7, 2026
This page shipped with no page.tsx. It was registered in both registries, appeared on the index, and loaded an error page. Its own test rendered the content component directly, which passed happily while the route did not exist — the test asserted the wrong thing, which is the more interesting half of the bug.
Adding the file was one line. The fix worth having is the guard: a test now walks the feature and write-up registries and checks the filesystem for a real page behind every internal link. It failed on the branch naming this exact page, and it will fail for the next registry entry someone adds without a route. A one-line fix would have left the same hole open.
The explorer is also laid out for a phone now, and it needed to be: on a 390px screen the tab row ran off the right edge, so two of six tabs were unreachable — Demographics clipped, Sources invisible entirely. The demographic rows pinned a fixed-width label and count against a flexible bar, which left the bar a sliver. Labels and numbers now share a line with the bar full-width beneath, tap targets are 44px, cards stack instead of cramping, and the provenance sentence in the header is hidden on small screens because it was pushing the first real number below the fold.
One correction worth recording. I read the phone screenshot as showing the population chips clipped at the right edge and changed a fieldset to a labelled group to fix it. Measuring the elements in the browser showed the chips had been wrapping correctly the whole time and I had mistaken the fold for a cut. The change stayed, because a row of toggle buttons genuinely should not be a fieldset, but it fixed nothing — and I would rather have that written down than quietly filed as a win.