Compare workflow + doctor-handoff print/PDF#4
Merged
Conversation
First commit of the compare/print workflow. Fixes the long-standing bug where the compare selection vanished every time the user refined their search — `compareSet` was local state in `ResultsList`, which re-mounts on `searchParams` change. App.jsx - New `compareSet` (Set<nctId>) + `pinnedTrialsRef` (Map<nctId, trial>). The Map cache populates atomically when a trial is pinned so the eventual compare view has the full trial object even if a refined search dropped it from the result set. In-memory only, no localStorage — privacy promise stays intact. - Three callbacks: `toggleCompare(trial)` (takes the whole trial, not just the ID, so the cache stays in sync), `clearCompare()`, `removeFromCompare(nctId)`. All wrapped in useCallback for stable reference identity. - COMPARE_LIMIT exported as a constant from App so the cap is defined in one place. ResultsList.jsx - Drops local `compareSet` + `toggleCompare`. Accepts compareSet, compareLimit, onToggleCompare, onClearCompare as props with EMPTY_SET / no-op defaults so tests and standalone usage still work. - New derived `staleCompareCount`: pinned NCTs that aren't in the current allTrials list. Surfaced in CompareBar. TriageRow.jsx - onToggleCompare now receives the whole trial object, not just the nctId, so App's cache can populate atomically. Single line change. CompareBar - Shows "(N not in current results)" mono caption when staleCount > 0. Title attribute clarifies they're saved, not lost. Compare button itself stays disabled with "Compare view coming soon" tooltip — the view comes in commits 2-3. What this commit does NOT do (deferred to next commits per the plan): - 2: useHashRoute + skeletal CompareView route - 3: Full CompareView field grid - 4: Print stylesheet + Print this trial button - 5: Print all from compare + page-break + summary index 197/197 tests still pass. Bundle delta negligible (~50 bytes).
Wires the route + navigation. Compare → button on the sticky bar now
goes somewhere instead of being a "coming soon" tooltip placeholder.
The view itself is intentionally minimal here — just lists pinned
trials by title with a back link and per-trial remove. The full
side-by-side field grid lands in the next commit.
useHashRoute (new src/hooks/useHashRoute.js)
- 25-line hook around window.location.hash + 'hashchange' event.
- Returns { route, navigate }. route is the hash without '#' so
consumers can just compare strings ('/compare' === route).
- Why not React Router: would add ~20 KB for one extra route. Hash
routing requires no build config and degrades gracefully (refresh
on /#/compare keeps you there).
CompareView (new src/components/CompareView.jsx)
- Renders pinned trials resolved from the cache (App's
pinnedTrialsRef Map). The cache is what makes a stale pin
renderable here — even if the trial isn't in the current
searchResult set anymore, we have the data.
- Header: back button, "compare trials" title, count caption.
- Empty state if compareSet is empty (shouldn't happen since the
Compare → button only appears when count > 0, but cheap to handle).
- Per-row remove button. Footer note flagging the next commit.
App.jsx
- Reads useHashRoute(). If route === '/compare', renders CompareView
with pinnedTrialsRef.current as the data source. The ref-not-state
pattern means the cache doesn't trigger re-renders on its own —
re-renders come from compareSet state changes, which is correct
(the Set is the source of truth for which IDs to render).
- Route check happens AFTER the test-route branches and the compare
state declarations so the same App instance handles both. No
duplicate state, no remount.
ResultsList.jsx
- CompareBar's Compare → button is now enabled and calls
window.location.hash = '/compare'. Direct over passing a callback
through props because it's a one-liner and avoids drilling
navigate() through CompareBar's props.
197/197 tests still pass.
Replaces the skeletal "list pinned titles" placeholder with the actual side-by-side comparison. Desktop (≥1024px / lg breakpoint) - Real <table> with field labels in the left column, one column per pinned trial. Native semantics, accessible by default, prints well. - Header row holds the trial title (serif) + NCT ID (mono caption) + per-column "remove" button. - 10 field rows: status, phase, intervention(s), nearest location, sex, age range, full eligibility criteria, brief summary, contact (name/phone/email), CT.gov link. Mobile / tablet portrait (<1024px) - Stacked cards, one per trial, each with the same fields as a 2-column dl (label / value). Decided against trying side-by-side at 768px — 3 columns at that width crushes the eligibility text to ~240px each and is unreadable. Field rendering helpers - formatPhase, locationLabel, ageRange, sexLabel kept inline. Some duplicate similar helpers in TriageRow / ResultCard — deferred hoisting to a separate refactor commit so this one stays focused. - Long-text fields (eligibility, summary) get whitespace-pre-wrap so CT.gov-style numbered lists render readably. - Status displayed as plain text label (not the colored pill from ResultCard) since the table layout doesn't have room for chrome. - Contact: stacked name / phone / email lines. What's next - Commit 4: print stylesheet + "Print this trial" button on detail pane - Commit 5: "Print all" from this compare view + page-break + index page
Native browser printing (window.print()) — zero-dep, searchable text in resulting PDF, native a11y, privacy-preserving (nothing leaves device). src/styles/print.css (new, imported once at app root) - @media print rules: hide chrome via .no-print, force-open <details>, reset app's screen-only flex/overflow so content paginates. - body[data-print-scope^="single-"] hides the list pane and lets the detail pane flow as a normal block. The attribute is set by the Print button's click handler and cleared on the matching afterprint event. - body[data-print-scope="compare"] adds page breaks between trials (used in the next commit by the compare-print flow). - a[href^="http"]::after spells out URLs after each link so a printed page is actionable on paper. - @page bottom-right margin box for "page N of M" (Chrome/Safari/Edge support; Firefox falls back to the in-body PrintMeta block). Components updated with .no-print - Header, Footer, UnifiedSearchBar (no-print on outer wrappers). - ResultsList: ResultsToolbar, list pane, CompareBar all gain no-print. The grid container + detail pane gain stable iris-results-grid / iris-list-pane / iris-detail-pane class hooks the print stylesheet keys off. ResultCard.jsx (pane mode only) - New PrintTrialButton next to the status pill. Sets data-print-scope="single-{nctId}" before window.print(), removes on afterprint. Has .no-print so the button itself doesn't print. - New PrintMeta block (.print-only — hidden in normal rendering) between the title and the meta line. Shows NCT ID + generated-on timestamp so the doctor receiving the printout has provenance. Test plan (manual) - Browse to a search result, click "print" on the detail pane → only the selected trial prints; chrome and list pane hidden; details expanded; URLs spelled out after each link. - Tested in Chrome / Safari / Firefox per the agent's plan.
…ial pages
Wraps the compare workflow: pin trials → click "Print all (N)" →
browser opens a print preview with a one-line index page (NCT / title
/ phase / status) followed by one full-detail page per trial. Save as
PDF, hand to oncologist.
CompareView changes
- New "Print all (N)" button in the header next to the count caption.
Sets body[data-print-scope="compare"] before window.print() so the
print stylesheet's page-break rules kick in. Cleanup on afterprint.
- New PrintCompare component (.print-only — hidden on screen). Renders:
- An index page: H1 "Clinical trial summary — N trials", generated-on
timestamp, then a 4-col table (NCT, Title, Phase, Status). Footer
paragraph explains IRIS to the receiving doctor.
- One <section className="iris-print-trial"> per trial, page-break-
before via print.css. Each section: serif H2 title, mono caption
(NCT · status · phase), then a <dl> of all 10 fields from the
shared FIELDS constant — same labels as the on-screen table.
- The on-screen table layout (CompareTable) is now wrapped in a
div.no-print-only — visible on screen, hidden in print. (The
inverse of .print-only.)
Why one trial per page (not side-by-side at print width)
- 8.5" minus 0.65" margins = 7.2" usable. Three columns at that width
is ~2.4" each — the eligibility text becomes unreadable.
- One full-detail page per trial gives the doctor every field at
legible size; the index page on top lets them scan.
Test plan (manual)
- Pin 2-3 trials, navigate to /#/compare, click "Print all" → browser
print preview shows index page + N trial pages, page breaks at the
right spots, no chrome bleed-through.
- Tested in Chrome / Safari per the agent's plan; Firefox falls back
to no @page margin boxes (still readable, just no page numbers).
This is the last commit of the compare/print workflow. Branch is now:
c1fd5b7 feat(compare): lift compare state to App + add stale-pin badge
0e0fe84 feat(compare): add useHashRoute + skeletal CompareView at #/compare
b215493 feat(compare): full side-by-side field grid in CompareView
f611c43 feat(print): print stylesheet + "Print this trial" button on detail pane
HEAD feat(print): "Print all" from CompareView with summary index + per-trial pages
Two messages were rendering at the same time during simplifier 'queued' state — the pretty iris-tinted pill (PipelineCaption stage="awaiting- summary" in ResultCard) and a plain italic line further down in the simplifier render block. Same content, two styles, looked broken. Drop the italic fallback. The pipeline caption is the single source — shows while queued (and during classifying when that's enabled), then disappears when tokens start streaming so the user sees the actual summary text appearing instead.
README audit + update against current state. Verified each claim before keeping it; replaced what's stale. Verified against source / WebFetch - Iris Long birth year: README had 1933 (correct per Wikipedia: Dec 8, 1933 – Apr 4, 2026). Footer.jsx had "1934–2026" (stale from the redesign branch). Footer is now corrected to match the README. - Test count: was 190, now 197. - Bundle: was 84 KB gzip, now 91 KB gzip after compare/print. - "Translates condition names to English": confirmed in nlpHelpers prompt — kept the claim. What got rewritten - Removed the "Why this might or might not fit you" / fit narrative paragraph — that section was dropped earlier this branch series because Gemma 2 2B's accuracy on the fit narrative wasn't reliable enough to ship. - NL flow now auto-fires search after extraction (no second click). Updated the "What it does" section to reflect. - Added the new "Compare and print" section (Layer 4 of the architecture diagram) covering pin-up-to-3, stale-pin badge, side-by-side view, and the native window.print() + @media print approach for the doctor handoff PDF. - Updated model registry to list all four registered models with their sizes and intended use (default vs harness-only). - Added the new ?test=classify harness alongside ?test=nlp and ?test=scenarios. - Added "Native browser print for the doctor handoff" technical highlight. - Added "Single-engine serialization" technical highlight covering the promise-chain pattern in useClassifier + useSimplifier. - Added CI row to the tech stack table (the workflow that landed in the redesign PR). - Roadmap rewritten: Phases 1-4 done, Phase 5 = wire classifier into results UI (gated today), Phase 6 = LoRA fine-tune (planning docs in vault). - Removed the "Lighthouse 100/100/100/100" claim — accurate at the time it was written but hasn't been re-run after the visual redesign + iris-violet palette + compare/print, so no longer verifiable. Add it back after a fresh Lighthouse pass. What stayed - Privacy claims, KV-cache reset, geocode centroid rejection, tree-shaken dev panels, streaming/queueing — all still accurate.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Wires the dormant compare scaffolding into a working end-to-end workflow and adds doctor-handoff PDF printing. A patient can now pin up to 3 trials (selection survives search refinements), open a side-by-side compare view, and either print a single trial or all pinned trials as a PDF to bring to their oncologist. Zero new runtime dependencies — uses native
window.print()+ a@media printstylesheet.Builds cleanly on top of
main(the merged Phase 1 visual redesign). Independent of the in-flight Phase 3 PR — these branches don't conflict.What's in
1. Compare-state lift (fixes a long-standing bug)
compareSet(Set) and a parallelpinnedTrialsRef(Map<nctId, trial>) cache lifted fromResultsListtoApp. Pins now survive search refinements that previously re-mountedResultsListand wiped local state.localStorage, consistent with the privacy promise.ResultsListnow acceptscompareSet,compareLimit,onToggleCompare,onClearCompareas props (with safe defaults for tests / standalone use).CompareBarshows "(N not in current results)" mono caption when stale pins exist — never silently drops.2. Hash routing (
useHashRoute)window.location.hash+hashchange. Returns{route, navigate}.Appchecksroute === '/compare'and rendersCompareView, otherwise renders the search/results UI.3. CompareView (new at
#/compare)<table>with field labels in the left column and one column per trial. 10 fields: status, phase, intervention(s), nearest location, sex, age range, full eligibility criteria, brief summary, contact (name/phone/email), CT.gov link.<dl>. Decided against side-by-side at 768px — 3 columns at that width crushes eligibility text to ~240px each.4. Print stylesheet + buttons
src/styles/print.css—@media printrules:.no-print(header, search bar, footer, list pane, sticky bar, all action buttons)<details>so the disclaimer expansion + clinical summary print without needing user clicksbody[data-print-scope^="single-"]hides the list pane and lets the detail pane flow as a normal blockbody[data-print-scope="compare"]adds page breaks between trialsa[href^="http"]::afterspells out URLs after each link so a printed page is actionable on paper@pagemargin box for "page N of M" (Chrome/Safari/Edge; Firefox falls back to in-body PrintMeta)Print this trialbutton on the detail pane (top-right, next to the status pill). Setsdata-print-scope="single-{nctId}", callswindow.print(), cleans up onafterprint.Print all (N)button in the CompareView header. Setsdata-print-scope="compare", prints a summary index page (NCT / title / phase / status table) followed by one full-detail page per trial with page breaks..print-only) injected into each printed trial: NCT ID prominently + generated-on timestamp + IRIS provenance line, so the receiving doctor knows when the snapshot was captured.5. Sundry fix
Decisions made (from the planning doc)
What's NOT in this PR
qrcode(~10 KB); URL is spelled out in plain text instead, which is sufficient for v1Test plan
npm run test:run— 197/197 pass (1 existing test updated for the duplicate-caption removal)npm run build— clean (286 → 298 KB JS, gzip 91.78 KB; ~10 KB delta from new CompareView + print.css)npm run lint— no new errors / warningsnpm run dev:Compare →→ side-by-side table renders with all 10 fieldsPrint all (N)→ browser preview shows index page + one trial per pageprintbutton → preview shows just that trialBranch state (5 commits + 1 fix)
Each commit is independently landable — if a reviewer wants to revert print but keep compare, that's clean.