From c1fd5b7b1a6ff943e5bde116a5e0474d20e3f2a9 Mon Sep 17 00:00:00 2001 From: John Orgera <65687576+johnoooh@users.noreply.github.com> Date: Thu, 7 May 2026 01:33:31 -0400 Subject: [PATCH 1/8] feat(compare): lift compare state to App + add stale-pin badge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) + `pinnedTrialsRef` (Map). 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). --- src/App.jsx | 51 ++++++++++++++++++++++++++++++++- src/components/ResultsList.jsx | 52 ++++++++++++++++++++++++---------- src/components/TriageRow.jsx | 2 +- 3 files changed, 88 insertions(+), 17 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index b4ef669..fac9e5d 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,4 +1,4 @@ -import { lazy, Suspense, useState } from 'react' +import { lazy, Suspense, useCallback, useRef, useState } from 'react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import Header from './components/Header' import UnifiedSearchBar from './components/UnifiedSearchBar' @@ -6,6 +6,8 @@ import ResultsList from './components/ResultsList' import Footer from './components/Footer' import { resolveModelKey } from './utils/nlpModels' +const COMPARE_LIMIT = 3 + const queryClient = new QueryClient() // Dev-only test harnesses. Both lazy() calls are gated on import.meta.env.DEV @@ -38,6 +40,48 @@ function IrisApp() { resolveModelKey(typeof window !== 'undefined' ? window.location.search : '') ) + // ─── Compare selection (lifted from ResultsList) ────────────────── + // Lives at App level so it survives search refinements (each new + // search re-mounts ResultsList, which would have wiped local state). + // pinnedTrials is a parallel cache of the full trial objects keyed + // by NCT ID — needed because a previously-pinned trial may not appear + // in the current result set, but the compare view still needs to + // render it. Cache is in-memory only (no localStorage) per the + // privacy promise. + const [compareSet, setCompareSet] = useState(() => new Set()) + const pinnedTrialsRef = useRef(new Map()) + + const toggleCompare = useCallback((trial) => { + if (!trial?.nctId) return + setCompareSet(prev => { + const next = new Set(prev) + if (next.has(trial.nctId)) { + next.delete(trial.nctId) + } else if (next.size < COMPARE_LIMIT) { + next.add(trial.nctId) + // Populate the cache the moment a trial is pinned so the + // compare view has the data even after the user refines + // their search and the result set no longer contains it. + pinnedTrialsRef.current.set(trial.nctId, trial) + } + return next + }) + }, []) + + const clearCompare = useCallback(() => { + setCompareSet(new Set()) + pinnedTrialsRef.current.clear() + }, []) + + const removeFromCompare = useCallback((nctId) => { + setCompareSet(prev => { + const next = new Set(prev) + next.delete(nctId) + return next + }) + pinnedTrialsRef.current.delete(nctId) + }, []) + const testRoute = getTestRoute() if (testRoute === 'nlp' && NLPTestPanel) { return ( @@ -90,6 +134,11 @@ function IrisApp() { modelKey={modelKey} userDescription={userDescription} extractedFields={prefill} + compareSet={compareSet} + compareLimit={COMPARE_LIMIT} + onToggleCompare={toggleCompare} + onClearCompare={clearCompare} + onRemoveFromCompare={removeFromCompare} /> )} diff --git a/src/components/ResultsList.jsx b/src/components/ResultsList.jsx index 12813dd..3c708b2 100644 --- a/src/components/ResultsList.jsx +++ b/src/components/ResultsList.jsx @@ -41,7 +41,23 @@ function patientDescFromFields(fields) { const EAGER_BATCH_SIZE = 5 const LIST_WIDTH_PX = 400 -export default function ResultsList({ searchParams, modelKey, userDescription, extractedFields }) { +// EMPTY_SET is referenced as a default for compareSet prop so tests + any +// caller that omits compare props still get a no-op compare experience. +const EMPTY_SET = new Set() + +export default function ResultsList({ + searchParams, + modelKey, + userDescription, + extractedFields, + // Compare state lifted to App so it survives search refinements. + // Defaults make the component usable in tests / standalone without + // requiring callers to wire all four props. + compareSet = EMPTY_SET, + compareLimit = 3, + onToggleCompare = null, + onClearCompare = () => {}, +}) { // Phase 3 simplification only ships for English and Spanish — those are // the languages we've verified the local model produces accurately. // Other languages get a "use browser translate" hint instead. @@ -83,7 +99,13 @@ export default function ResultsList({ searchParams, modelKey, userDescription, e const isMobile = useIsMobile() const [selectedNctId, setSelectedNctId] = useState(null) const [sheetOpen, setSheetOpen] = useState(false) - const [compareSet, setCompareSet] = useState(() => new Set()) + + // Stale pins — NCTs in compareSet that aren't in the current result + // page. Surfaced as a small badge in the sticky CompareBar so users + // know their pin is preserved even if a refined search dropped it + // from view. + const visibleCompareIds = new Set(allTrials.map(t => t.nctId).filter(id => compareSet.has(id))) + const staleCompareCount = compareSet.size - visibleCompareIds.size // ─── Stage-1 classification ─────────────────────────────────────── // Only fires when the user previously consented to the on-device model @@ -139,15 +161,6 @@ export default function ResultsList({ searchParams, modelKey, userDescription, e // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchParams, patientDesc]) - function toggleCompare(nctId) { - setCompareSet(prev => { - const next = new Set(prev) - if (next.has(nctId)) next.delete(nctId) - else if (next.size < 3) next.add(nctId) - return next - }) - } - // Default selection to the first trial when results arrive (desktop only — // on mobile we wait for an explicit tap to open the sheet). useEffect(() => { @@ -355,8 +368,8 @@ export default function ResultsList({ searchParams, modelKey, userDescription, e selected={!isMobile && trial.nctId === selectedNctId} onSelect={onSelectTrial} comparing={compareSet.has(trial.nctId)} - onToggleCompare={toggleCompare} - compareDisabled={compareSet.size >= 3} + onToggleCompare={onToggleCompare} + compareDisabled={compareSet.size >= compareLimit} classification={canClassify ? classifications.get(trial.nctId) : null} classifyPending={canClassify && !classifications.has(trial.nctId)} /> @@ -384,7 +397,8 @@ export default function ResultsList({ searchParams, modelKey, userDescription, e {compareSet.size > 0 && ( setCompareSet(new Set())} + staleCount={staleCompareCount} + onClear={onClearCompare} /> )} @@ -456,7 +470,7 @@ function ResultsToolbar({ totalCount, searchParams, classifyProgress }) { ) } -function CompareBar({ count, onClear }) { +function CompareBar({ count, staleCount = 0, onClear }) { return (
{count} in compare + {staleCount > 0 && ( + + ({staleCount} not in current results) + + )}
+

+ compare trials +

+ + {trials.length} pinned + + + +
+ {trials.length === 0 ? ( +

+ No trials pinned yet. Use the checkbox on each trial to add up to 3. +

+ ) : ( +
    + {trials.map(trial => ( +
  • +
    +

    + {trial.title} +

    +

    + {trial.nctId} +

    +
    + +
  • + ))} +
+ )} + +

+ Side-by-side field grid + print export coming in the next commits. +

+
+
+ ) +} diff --git a/src/components/ResultsList.jsx b/src/components/ResultsList.jsx index 3c708b2..cd4505d 100644 --- a/src/components/ResultsList.jsx +++ b/src/components/ResultsList.jsx @@ -399,6 +399,7 @@ export default function ResultsList({ count={compareSet.size} staleCount={staleCompareCount} onClear={onClearCompare} + onCompare={() => { window.location.hash = '/compare' }} /> )} @@ -470,7 +471,7 @@ function ResultsToolbar({ totalCount, searchParams, classifyProgress }) { ) } -function CompareBar({ count, staleCount = 0, onClear }) { +function CompareBar({ count, staleCount = 0, onClear, onCompare }) { return (
diff --git a/src/hooks/useHashRoute.js b/src/hooks/useHashRoute.js new file mode 100644 index 0000000..eb0441f --- /dev/null +++ b/src/hooks/useHashRoute.js @@ -0,0 +1,27 @@ +import { useEffect, useState } from 'react' + +// Tiny hash-route hook. Why hash routing instead of React Router: +// IRIS only needs one extra route today (/compare). Pulling React Router +// in for that one route would add ~20 KB and rewire App.jsx; a 25-line +// hook is enough. +// +// `route` is the hash with the leading '#' stripped (e.g. "/compare"). +// `navigate(target)` accepts either '/foo' or '#/foo'; both work. +export function useHashRoute() { + const [hash, setHash] = useState(() => + typeof window !== 'undefined' ? window.location.hash : '' + ) + + useEffect(() => { + const onHashChange = () => setHash(window.location.hash) + window.addEventListener('hashchange', onHashChange) + return () => window.removeEventListener('hashchange', onHashChange) + }, []) + + function navigate(target) { + const next = target.startsWith('#') ? target.slice(1) : target + window.location.hash = next + } + + return { route: hash.replace(/^#/, ''), navigate } +} From b2154939065567ed988830163a2423c5c92794f5 Mon Sep 17 00:00:00 2001 From: John Orgera <65687576+johnoooh@users.noreply.github.com> Date: Thu, 7 May 2026 01:36:23 -0400 Subject: [PATCH 3/8] feat(compare): full side-by-side field grid in CompareView MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the skeletal "list pinned titles" placeholder with the actual side-by-side comparison. Desktop (≥1024px / lg breakpoint) - Real 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 --- src/components/CompareView.jsx | 221 +++++++++++++++++++++++++++------ 1 file changed, 186 insertions(+), 35 deletions(-) diff --git a/src/components/CompareView.jsx b/src/components/CompareView.jsx index 64dfa88..ef1048a 100644 --- a/src/components/CompareView.jsx +++ b/src/components/CompareView.jsx @@ -1,12 +1,95 @@ -// Skeletal compare view — just lists pinned trials by title with a back -// button. The full side-by-side field grid lands in the next commit; this -// is enough to wire the route + Compare → button so the navigation flow -// can be exercised before the layout work. +// Side-by-side compare view for up to 3 pinned trials. +// +// Desktop (≥900px): table layout with field labels in the left column and +// one column per trial. Familiar tabular comparison. +// +// Mobile (<900px): stacked cards, one per trial, each with the same field +// rows. The table's responsive collapse uses `display: block` on td/tr — +// preserves semantics for screen readers but lets each "row" of one trial +// flow as its own block. (Decided against trying 2-col at tablet — at +// 768px each trial column is ~240px which crushes the eligibility text.) + +import { nearestLocation } from '../utils/apiHelpers' + +const STATUS_LABEL = { + RECRUITING: 'Recruiting', + NOT_YET_RECRUITING: 'Not yet recruiting', + ACTIVE_NOT_RECRUITING: 'Active, not recruiting', + COMPLETED: 'Completed', + TERMINATED: 'Terminated', + WITHDRAWN: 'Withdrawn', +} + +const PHASE_SHORT = { + EARLY_PHASE1: 'Early Phase 1', + PHASE1: 'Phase 1', + PHASE2: 'Phase 2', + PHASE3: 'Phase 3', + PHASE4: 'Phase 4', + NA: 'N/A', +} + +function formatPhase(phases) { + if (!phases?.length) return '—' + return phases.map(p => PHASE_SHORT[p] ?? p).join(' / ') +} + +function locationLabel(trial) { + const nearest = nearestLocation(trial.locations, null) + if (!nearest) { + if (trial.locations?.length) { + const l = trial.locations[0] + return [l.facility, l.city, l.state].filter(Boolean).join(', ') + } + return '—' + } + return [nearest.facility, nearest.city, nearest.state].filter(Boolean).join(', ') +} + +function ageRange(eligibility) { + const min = eligibility?.minAge + const max = eligibility?.maxAge + if (!min && !max) return '—' + return `${min || 'N/A'} – ${max || 'N/A'}` +} + +function sexLabel(sex) { + if (!sex || sex === 'ALL') return 'Any' + return sex.charAt(0) + sex.slice(1).toLowerCase() +} + +const FIELDS = [ + { key: 'status', label: 'Status', render: (t) => STATUS_LABEL[t.status] || t.status || '—' }, + { key: 'phase', label: 'Phase', render: (t) => formatPhase(t.phases) }, + { key: 'intervention', label: 'Intervention', render: (t) => t.interventions?.map(i => i.name).filter(Boolean).join(', ') || '—' }, + { key: 'location', label: 'Nearest location', render: locationLabel }, + { key: 'sex', label: 'Sex', render: (t) => sexLabel(t.eligibility?.sex) }, + { key: 'age', label: 'Age range', render: (t) => ageRange(t.eligibility) }, + { key: 'eligibility', label: 'Eligibility criteria', render: (t) => t.eligibility?.criteria || '—', long: true }, + { key: 'summary', label: 'Brief summary', render: (t) => t.summary || '—', long: true }, + { key: 'contact', label: 'Contact', render: (t) => contactBlock(t) }, + { key: 'link', label: 'ClinicalTrials.gov', render: (t) => ( + + {t.nctId} + + )}, +] + +function contactBlock(trial) { + const c = trial.contact || {} + const parts = [] + if (c.name) parts.push(c.name) + if (c.phone) parts.push(c.phone) + if (c.email) parts.push({c.email}) + if (parts.length === 0) return '—' + return ( +
+ {parts.map((p, i) => {p})} +
+ ) +} export default function CompareView({ compareSet, pinnedTrials, onBack, onRemove }) { - // Resolve the Set of NCT IDs against the pinned-trials cache. The cache - // was populated when the user pinned each trial, so it has data even if - // the current search no longer returns those trials. const trials = [] for (const nctId of compareSet) { const trial = pinnedTrials.get(nctId) @@ -31,43 +114,111 @@ export default function CompareView({ compareSet, pinnedTrials, onBack, onRemove -
+
{trials.length === 0 ? (

No trials pinned yet. Use the checkbox on each trial to add up to 3.

) : ( -
    - {trials.map(trial => ( -
  • -
    + + )} +
+ + ) +} + +function CompareTable({ trials, onRemove }) { + return ( + <> + {/* Desktop: real table, side-by-side */} +
+ + + {trials.map(t => )} + + + + ))} - - )} + + + + {FIELDS.map(field => ( + + + {trials.map(t => ( + + ))} + + ))} + +
+ {trials.map(t => ( + +

- {trial.title} + {t.title}

-

- {trial.nctId} -

+
- - +

{t.nctId}

+
+ {field.label} + + {field.render(t)} +
-

- Side-by-side field grid + print export coming in the next commits. -

- -
+ {/* Mobile / tablet portrait: stacked cards, one per trial */} +
+ {trials.map(t => ( +
+
+
+

+ {t.title} +

+

{t.nctId}

+
+ +
+
+ {FIELDS.map(field => ( +
+
+ {field.label} +
+
+ {field.render(t)} +
+
+ ))} +
+
+ ))} +
+ ) } From f611c435615b8a6a3dea66c111a76f7c961cef06 Mon Sep 17 00:00:00 2001 From: John Orgera <65687576+johnoooh@users.noreply.github.com> Date: Thu, 7 May 2026 01:41:07 -0400 Subject: [PATCH 4/8] feat(print): print stylesheet + "Print this trial" button on detail pane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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
, 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. --- src/components/Footer.jsx | 2 +- src/components/Header.jsx | 2 +- src/components/ResultCard.jsx | 46 ++++++++++++++++ src/components/ResultsList.jsx | 10 ++-- src/components/UnifiedSearchBar.jsx | 2 +- src/index.css | 1 + src/styles/print.css | 85 +++++++++++++++++++++++++++++ 7 files changed, 140 insertions(+), 8 deletions(-) create mode 100644 src/styles/print.css diff --git a/src/components/Footer.jsx b/src/components/Footer.jsx index 2fd1328..8e7ba98 100644 --- a/src/components/Footer.jsx +++ b/src/components/Footer.jsx @@ -1,6 +1,6 @@ export default function Footer() { return ( -