feat(drug-reference): tabbed redesign with grouped search, multi-select situations, and a disclaimer gate (#1137)

* feat(drug-reference): compact header + single dashboard tile

Phase 1 of the drug-reference redesign:
- AppLayout gains an opt-in `compact` prop (small inline logo+title) so tool
  pages reclaim the ~230px the full branding block costs; drug-reference/index
  opts in.
- Consolidate the two dashboard tiles (Drug Reference + When to use what) into a
  single Drug Reference tile with a broadened description (/conditions already
  redirects to /drug-reference).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(drug-reference): tabbed redesign — search-by-drug + by-situation + FDA data

- Split /drug-reference into three tabs (Headless UI TabGroup): 'Search by
  drug', 'By situation', 'FDA data'. Each tab runs only its own direction,
  which removes the two-overlapping-sections confusion.
- Search by drug: results grouped by active ingredient (IngredientGroup,
  single-ingredient groups first, combos after), drug-first result rows, and a
  collapsible de-jargoned filter drawer (Over-the-counter / Prescription, Form,
  Sort) that auto-collapses once results land.
- By situation: multi-select symptom chips → an 'Treats all N selected'
  intersection section pinned on top (computed client-side from each
  situation's result set) + one union group per situation.
- FDA data: the download/ingest control + status moved behind its own tab
  (the pre-ingest empty state stays the prominent download prompt).
- DrugResultRow now leads with the active ingredient (drug-first) by default,
  or the brand when rendered inside an ingredient group.
- Rename 'Compare interactions' → 'Compare label warnings' to match what it does.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(drug-reference): rank situation matches single-ingredient-first

The raw FDA indication match floods a situation (e.g. Headache) with
many-ingredient homeopathic products, burying real OTC drugs and leaving the
cross-situation intersection empty. Pull a wider result set (200) and sort by
active-ingredient count ascending — not a medical judgement, the same
'single-ingredient first' principle as the drug-search grouping. Now real
drugs (acetaminophen, ibuprofen) surface on top and the 'Treats all N selected'
intersection actually finds the shared OTC options. Per-situation cards cap the
display to the top 25 (ranked), intersection uses the full set.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(drug-reference): first-open disclaimer gate + first-group-expanded

- Add a required disclaimer modal on first open of the Drug Reference (Jake's
  mechanism): comprehensive not-medical-advice notice the user must acknowledge;
  acceptance is saved to the browser's localStorage (versioned key) so it isn't
  shown again on that browser, while new browsers/devices see it on first open.
  Non-dismissible (no backdrop/Escape) — only the acknowledge button closes it.
- Search-by-drug: expand the first ingredient group by default, collapse all
  subsequent groups (IngredientGroup gains an explicit defaultOpen prop,
  replacing the size heuristic).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(drug-reference): intersection-first multi-select

Multiple situations now lead with the 'Treats all N selected' intersection and
only break out per-situation sections when the intersection is empty (nothing
treats all) — with a 'No single option treats all N of these' explainer. Keeps
the view combined when there's a shared answer, and only fragments as a fallback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(drug-reference): compact header on detail/interactions/conditions pages

Apply the compact AppLayout header to the drug detail, interactions, and
condition pages so they match the redesigned index instead of the full-height
branding block.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(drug-reference): rename interactions heading to 'Compare label warnings'

Match the page heading + title to the button label, so the name reflects what
the view does (each drug's own FDA-labeled warnings side by side, not a
cross-drug interaction checker).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
chriscrosstalk 2026-07-23 22:02:54 -05:00 committed by jakeaturner
parent a128079c11
commit 69080b3a05
No known key found for this signature in database
GPG Key ID: B1072EBDEECE328D
9 changed files with 894 additions and 618 deletions

View File

@ -0,0 +1,93 @@
import { Dialog, DialogBackdrop, DialogPanel, DialogTitle } from '@headlessui/react'
import { IconAlertTriangle } from '@tabler/icons-react'
import StyledButton from '~/components/StyledButton'
/**
* localStorage key for the Drug Reference disclaimer acknowledgement. Versioned:
* bump the suffix if the disclaimer text changes materially so every browser is
* re-prompted. Per-browser by design a new browser/device gets the gate again.
*/
export const DRUG_DISCLAIMER_ACK_KEY = 'nomad:drugReferenceDisclaimer:v1'
export function hasAcknowledgedDrugDisclaimer(): boolean {
if (typeof window === 'undefined') return true
try {
return window.localStorage.getItem(DRUG_DISCLAIMER_ACK_KEY) === 'ack'
} catch {
return false
}
}
/**
* First-open disclaimer gate for the Drug Reference. Blocks the page until the
* user acknowledges (no backdrop / Escape dismissal). On acknowledgement the
* acceptance is saved to this browser's localStorage so it isn't shown again on
* this browser other browsers/devices see it on their first open.
*/
export default function DrugDisclaimerModal({ open, onAcknowledge }: { open: boolean; onAcknowledge: () => void }) {
const acknowledge = () => {
try {
window.localStorage.setItem(DRUG_DISCLAIMER_ACK_KEY, 'ack')
} catch {
// Private mode / storage disabled — still let them through for this session.
}
onAcknowledge()
}
return (
<Dialog open={open} onClose={() => {}} className="relative z-50">
<DialogBackdrop className="fixed inset-0 bg-black/60" />
<div className="fixed inset-0 z-10 w-screen overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 sm:items-center sm:p-0">
<DialogPanel className="relative w-full transform overflow-hidden rounded-lg bg-surface-primary px-5 pb-5 pt-6 text-left shadow-xl transition-all sm:my-8 sm:max-w-lg sm:p-6">
<div className="flex flex-col items-center text-center">
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-desert-orange/15 text-desert-orange-dark">
<IconAlertTriangle size={26} />
</span>
<DialogTitle as="h3" className="mt-4 text-lg font-bold text-text-primary">
Before you use the Drug Reference
</DialogTitle>
</div>
<div className="mt-4 space-y-3 text-sm text-text-secondary">
<p>
This tool shows general health information from official <strong>FDA drug labels</strong> and
matches symptoms to over-the-counter options. It is provided for <strong>information only</strong>.
</p>
<ul className="list-disc space-y-1.5 pl-5">
<li>
It is <strong>not medical advice</strong> and not a substitute for a doctor, pharmacist, or nurse.
</li>
<li>
It is <strong>not a drug-interaction checker</strong>. Always read each product&rsquo;s full label
and check with a professional before combining medicines.
</li>
<li>
Situation matches come from label text, not clinical recommendations they can be incomplete or
include products you wouldn&rsquo;t expect.
</li>
<li>
Always follow the directions on the <strong>actual product you have</strong>; dosages and warnings
differ between products.
</li>
<li>
In an emergency, or if symptoms are severe, worsening, or you&rsquo;re unsure,{' '}
<strong>contact a medical professional or call emergency services</strong>.
</li>
</ul>
<p className="text-xs text-text-muted">
Data is from openFDA (U.S. FDA, public domain). NOMAD is not affiliated with or endorsed by the FDA.
</p>
</div>
<div className="mt-6">
<StyledButton variant="action" fullWidth onClick={acknowledge}>
I understand continue
</StyledButton>
</div>
</DialogPanel>
</div>
</div>
</Dialog>
)
}

View File

@ -4,18 +4,46 @@ import { PRODUCT_TYPES } from '../../../types/drug_reference'
interface Props {
result: DrugSearchResult
/**
* Inside an ingredient group the active ingredient is already the group header,
* so lead with the brand (product identity) instead of repeating the ingredient.
* Ungrouped (default) leads with the active ingredient the thing users think in.
*/
brandFirst?: boolean
}
/** Title-case an UPPERCASE ingredient string (IBUPROFEN → Ibuprofen). */
function titleCase(s: string): string {
return s
.toLowerCase()
.replace(/\b([a-z])/g, (m) => m.toUpperCase())
}
/**
* A single collapsed search result row.
*
* Shows brand name, generic name, OTC/Rx badge, route, and a "N labels"
* chip when more than one set_id collapsed into this result.
* A single collapsed search result row. Leads with the active ingredient by
* default (drug-first), or the brand when rendered inside an ingredient group.
*/
export default function DrugResultRow({ result }: Props) {
export default function DrugResultRow({ result, brandFirst = false }: Props) {
const isRx = result.product_type === PRODUCT_TYPES.RX
const isOtc = result.product_type === PRODUCT_TYPES.OTC
const ingredient = result.generic_name ? titleCase(result.generic_name) : null
const brand = result.brand_name ?? null
// Headline vs sub-line depending on context.
const headline = brandFirst
? (brand ?? ingredient ?? 'Unknown')
: (ingredient ?? brand ?? 'Unknown')
const subParts: string[] = []
if (brandFirst) {
if (result.manufacturer) subParts.push(result.manufacturer)
} else {
// Ingredient-first: show the brand (if it differs from the ingredient) + maker.
if (brand && brand.toLowerCase() !== (ingredient ?? '').toLowerCase()) subParts.push(brand)
if (result.manufacturer) subParts.push(result.manufacturer)
}
if (result.route) subParts.push(titleCase(result.route))
return (
<Link
href={`/drug-reference/${result.id}`}
@ -24,10 +52,9 @@ export default function DrugResultRow({ result }: Props) {
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-semibold text-sm text-gray-900 group-hover:text-desert-green truncate">
{result.brand_name ?? result.generic_name ?? 'Unknown'}
{headline}
</span>
{/* OTC / Rx badge */}
{isRx && (
<span className="px-1.5 py-0.5 rounded text-xs font-semibold bg-desert-orange/10 text-desert-orange-dark border border-desert-orange/30 flex-shrink-0">
Rx
@ -39,7 +66,6 @@ export default function DrugResultRow({ result }: Props) {
</span>
)}
{/* Collapsed labels count */}
{result.labelCount > 1 && (
<span className="px-1.5 py-0.5 rounded text-xs bg-gray-100 text-gray-600 flex-shrink-0">
{result.labelCount} labels
@ -47,15 +73,15 @@ export default function DrugResultRow({ result }: Props) {
)}
</div>
<div className="flex flex-wrap gap-3 mt-0.5 text-xs text-gray-500">
{result.brand_name && result.generic_name && (
<span className="italic truncate">{result.generic_name}</span>
)}
{result.manufacturer && (
<span className="truncate">{result.manufacturer}</span>
)}
{result.route && <span>{result.route}</span>}
</div>
{subParts.length > 0 && (
<div className="flex flex-wrap gap-x-3 mt-0.5 text-xs text-gray-500">
{subParts.map((p, i) => (
<span key={i} className="truncate">
{p}
</span>
))}
</div>
)}
</div>
<span className="ml-3 text-gray-400 text-xs flex-shrink-0"></span>

View File

@ -0,0 +1,73 @@
import { useState } from 'react'
import { IconChevronDown } from '@tabler/icons-react'
import DrugResultRow from './DrugResultRow'
import type { DrugSearchResult } from '../../../types/drug_reference'
import { PRODUCT_TYPES } from '../../../types/drug_reference'
export interface IngredientGrouping {
key: string
/** Display name (title-cased ingredient list). */
label: string
/** True when this group is a single active ingredient (ranked above combos). */
single: boolean
products: DrugSearchResult[]
}
/**
* A collapsible "active ingredient → its products" group for the drug-name search.
* A single-product group renders as a plain (ingredient-first) row; multi-product
* groups collapse behind the ingredient name so the list stays scannable.
*/
export default function IngredientGroup({
group,
defaultOpen = false,
}: {
group: IngredientGrouping
/** Expand this group on first render (used for the top/first result group). */
defaultOpen?: boolean
}) {
const [open, setOpen] = useState(defaultOpen)
if (group.products.length === 1) {
return <DrugResultRow result={group.products[0]} />
}
const anyOtc = group.products.some((p) => p.product_type === PRODUCT_TYPES.OTC)
const anyRx = group.products.some((p) => p.product_type === PRODUCT_TYPES.RX)
return (
<div>
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="flex w-full items-center gap-2 px-4 py-3 text-left hover:bg-gray-50 transition-colors"
>
<IconChevronDown
size={16}
className={`flex-shrink-0 text-desert-stone transition-transform ${open ? 'rotate-0' : '-rotate-90'}`}
/>
<span className="font-semibold text-sm text-desert-green-darker truncate">{group.label}</span>
{anyOtc && (
<span className="px-1.5 py-0.5 rounded text-[10px] font-semibold bg-desert-olive/10 text-desert-olive-dark border border-desert-olive/30 flex-shrink-0">
OTC
</span>
)}
{anyRx && (
<span className="px-1.5 py-0.5 rounded text-[10px] font-semibold bg-desert-orange/10 text-desert-orange-dark border border-desert-orange/30 flex-shrink-0">
Rx
</span>
)}
<span className="ml-auto text-xs text-desert-stone flex-shrink-0">
{group.products.length} products
</span>
</button>
{open && (
<div className="divide-y divide-desert-stone-lighter/40 border-t border-desert-stone-lighter/30 bg-desert-sand/10">
{group.products.map((d) => (
<DrugResultRow key={d.id} result={d} brandFirst />
))}
</div>
)}
</div>
)
}

View File

@ -8,7 +8,18 @@ import { Link, router } from '@inertiajs/react'
import { IconArrowLeft } from '@tabler/icons-react'
import classNames from 'classnames'
export default function AppLayout({ children }: { children: React.ReactNode }) {
export default function AppLayout({
children,
compact = false,
}: {
children: React.ReactNode
/**
* Compact header for focused tool pages (e.g. Drug Reference): a small inline
* logo + title instead of the full-height branding block, so the tool's own
* controls sit near the top of the viewport instead of ~230px down.
*/
compact?: boolean
}) {
const [isChatOpen, setIsChatOpen] = useState(false)
const aiAssistantInstalled = useServiceInstalledStatus(SERVICE_NAMES.OLLAMA)
@ -16,22 +27,42 @@ export default function AppLayout({ children }: { children: React.ReactNode }) {
<div className="min-h-screen flex flex-col">
{
window.location.pathname !== '/home' && (
<Link href="/home" className="absolute top-60 md:top-48 left-4 flex items-center">
<Link
href="/home"
className={classNames(
'absolute left-4 flex items-center',
compact ? 'top-4' : 'top-60 md:top-48'
)}
>
<IconArrowLeft className="mr-2" size={24} />
<p className="text-lg text-text-secondary">Back to Home</p>
</Link>
)}
<div
className="p-2 flex gap-2 flex-col items-center justify-center cursor-pointer"
className={classNames(
'flex cursor-pointer items-center justify-center',
compact ? 'gap-3 p-3 flex-row' : 'gap-2 p-2 flex-col'
)}
onClick={() => router.visit('/home')}
>
<img src="/project_nomad_logo.webp" alt="Project NOMAD Logo" className="h-40 w-40" />
<h1 className="text-5xl font-bold text-desert-green">Command Center</h1>
<img
src="/project_nomad_logo.webp"
alt="Project NOMAD Logo"
className={compact ? 'h-12 w-12' : 'h-40 w-40'}
/>
<h1
className={classNames(
'font-bold text-desert-green',
compact ? 'text-2xl' : 'text-5xl'
)}
>
Command Center
</h1>
</div>
<hr className={
classNames(
"text-desert-green font-semibold h-[1.5px] bg-desert-green border-none",
window.location.pathname !== '/home' ? "mt-12 md:mt-0" : "mt-0"
!compact && window.location.pathname !== '/home' ? "mt-12 md:mt-0" : "mt-0"
)} />
<div className="flex-1 w-full bg-desert">{children}</div>
<Footer />

View File

@ -29,7 +29,7 @@ export default function ConditionsShow({ condition, drugs, remedies, drugRowCoun
const noData = drugRowCount === 0
return (
<AppLayout>
<AppLayout compact>
<Head title={label} />
<div className="p-4 max-w-3xl mx-auto">

File diff suppressed because it is too large Load Diff

View File

@ -121,8 +121,8 @@ export default function DrugReferenceInteractions({ ingestStatus, rowCount }: Pa
const atMax = selectedIds.length >= MAX_COMPARE
return (
<AppLayout>
<Head title="Compare Drug Interactions" />
<AppLayout compact>
<Head title="Compare label warnings" />
<div className="p-4 max-w-7xl mx-auto">
{/* Back nav */}
@ -135,7 +135,7 @@ export default function DrugReferenceInteractions({ ingestStatus, rowCount }: Pa
</Link>
<div className="mb-5">
<h1 className="text-2xl font-bold mb-1">Compare Drug Interactions</h1>
<h1 className="text-2xl font-bold mb-1">Compare label warnings</h1>
<p className="text-sm opacity-70">
View each drug's FDA-labeled interaction warnings side by side. Select up to {MAX_COMPARE} drugs.
</p>

View File

@ -24,7 +24,7 @@ export default function DrugReferenceShow({ label, situations = [] }: PageProps)
const isOtc = label.product_type === PRODUCT_TYPES.OTC
return (
<AppLayout>
<AppLayout compact>
<Head title={label.brand_name ?? label.generic_name ?? 'Drug Detail'} />
<div className="p-4 max-w-3xl mx-auto">

View File

@ -1,7 +1,6 @@
import {
IconBolt,
IconBox,
IconFirstAidKit,
IconHelp,
IconMapRoute,
IconPill,
@ -44,24 +43,13 @@ const DRUG_REFERENCE_ITEM = {
label: 'Drug Reference',
to: '/drug-reference',
target: '',
description: 'Offline FDA drug labels: search by drug name',
description: 'Offline FDA drug labels — search by drug name, or by situation (burn, fever, diarrhea)',
icon: <IconPill size={48} />,
installed: true,
displayOrder: 5,
poweredBy: null,
}
const CONDITIONS_ITEM = {
label: 'When to use what',
to: '/conditions',
target: '',
description: 'Match a situation (burn, fever, diarrhea) to the right OTC drugs',
icon: <IconFirstAidKit size={48} />,
installed: true,
displayOrder: 6,
poweredBy: null,
}
// System items shown after all apps
const SYSTEM_ITEMS = [
{
@ -178,7 +166,6 @@ export default function Home(props: {
// same drug_labels table, so they gate together off one server-computed flag.
if (props.drugReferenceInstalled) {
items.push(DRUG_REFERENCE_ITEM)
items.push(CONDITIONS_ITEM)
}
// Add system items