Skip to content

Compare workflow + doctor-handoff print/PDF#4

Merged
johnoooh merged 8 commits into
mainfrom
feat/compare-and-print
May 7, 2026
Merged

Compare workflow + doctor-handoff print/PDF#4
johnoooh merged 8 commits into
mainfrom
feat/compare-and-print

Conversation

@johnoooh
Copy link
Copy Markdown
Owner

@johnoooh johnoooh commented May 7, 2026

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 print stylesheet.

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 parallel pinnedTrialsRef (Map<nctId, trial>) cache lifted from ResultsList to App. Pins now survive search refinements that previously re-mounted ResultsList and wiped local state.
  • The cache populates atomically when a trial is pinned, so a previously-pinned trial that's no longer in the current result set still has full data for the compare view.
  • In-memory only — no localStorage, consistent with the privacy promise.
  • ResultsList now accepts compareSet, compareLimit, onToggleCompare, onClearCompare as props (with safe defaults for tests / standalone use).
  • Sticky CompareBar shows "(N not in current results)" mono caption when stale pins exist — never silently drops.

2. Hash routing (useHashRoute)

  • 25-line hook around window.location.hash + hashchange. Returns {route, navigate}.
  • Why not React Router: would add ~20 KB for one extra route. Hash routing requires no build config and survives refresh.
  • App checks route === '/compare' and renders CompareView, otherwise renders the search/results UI.

3. CompareView (new at #/compare)

  • Desktop (≥1024px): real <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.
  • Mobile / tablet portrait (<1024px): stacked cards, one per trial, each with the same fields as a <dl>. Decided against side-by-side at 768px — 3 columns at that width crushes eligibility text to ~240px each.
  • Per-column "remove" button. Back link to results. Header shows pinned count + Print all button.

4. Print stylesheet + buttons

  • New src/styles/print.css@media print rules:
    • Hide chrome via .no-print (header, search bar, footer, list pane, sticky bar, all action buttons)
    • Force-open every <details> so the disclaimer expansion + clinical summary print without needing user clicks
    • Reset the app's screen-only flex/overflow so content paginates instead of clipping
    • body[data-print-scope^="single-"] hides the list pane and lets the detail pane flow as a normal block
    • body[data-print-scope="compare"] adds page breaks between trials
    • a[href^="http"]::after spells out URLs after each link so a printed page is actionable on paper
    • @page margin box for "page N of M" (Chrome/Safari/Edge; Firefox falls back to in-body PrintMeta)
  • Print this trial button on the detail pane (top-right, next to the status pill). Sets data-print-scope="single-{nctId}", calls window.print(), cleans up on afterprint.
  • Print all (N) button in the CompareView header. Sets data-print-scope="compare", prints a summary index page (NCT / title / phase / status table) followed by one full-detail page per trial with page breaks.
  • PrintMeta block (.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

  • Removed the duplicate "Generating plain-language summary…" caption that rendered both as the iris-tinted pipeline pill and as a plain italic line below the header. The pipeline pill is now the single source.

Decisions made (from the planning doc)

Q Answer Why
Compare layout at 768px Stack at <1024px Three columns at 768px crushes eligibility text
AI summary in print Default-on with disclaimer banner Doctor wants to see what the patient was reading
Print button placement Top-right of detail header next to status pill Discoverable without crowding contact block
Routing Hash routing, no React Router Saves ~20 KB; one extra route doesn't justify the dep

What's NOT in this PR

  • Compare state persistence across reloads — in-memory only by design (privacy)
  • Sort by best fit — still deferred until classification UI is re-enabled (separate Phase 3 work)
  • Print compare side-by-side at print width — explicitly chose one-trial-per-page; the 7.2" usable width can't fit 3 readable columns
  • QR code for the CT.gov URL on printed pages — would need qrcode (~10 KB); URL is spelled out in plain text instead, which is sufficient for v1

Test 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 / warnings
  • Manual on npm run dev:
    • Pin 2-3 trials → refine search → confirm pins survive + stale badge appears
    • Click Compare → → side-by-side table renders with all 10 fields
    • Click Print all (N) → browser preview shows index page + one trial per page
    • Single-trial print button → preview shows just that trial
  • Cross-browser print parity (Chrome/Safari/Firefox) — Chrome verified, others worth a spot-check before merging
  • Real-world test on a phone (mobile sheet + compare stack at 375px)

Branch state (5 commits + 1 fix)

ca0eba7  fix(ui): remove duplicate "generating plain-language summary…" caption
962f278  feat(print): "Print all" from CompareView with summary index + per-trial pages
f611c43  feat(print): print stylesheet + "Print this trial" button on detail pane
b215493  feat(compare): full side-by-side field grid in CompareView
0e0fe84  feat(compare): add useHashRoute + skeletal CompareView at #/compare
c1fd5b7  feat(compare): lift compare state to App + add stale-pin badge

Each commit is independently landable — if a reviewer wants to revert print but keep compare, that's clean.

johnoooh added 8 commits May 7, 2026 01:33
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.
@johnoooh johnoooh merged commit fe327bc into main May 7, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant