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:
parent
a128079c11
commit
69080b3a05
|
|
@ -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’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’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’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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue