From 69080b3a0536aed5b3df2159854d8a9890c1d025 Mon Sep 17 00:00:00 2001 From: chriscrosstalk <49691103+chriscrosstalk@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:02:54 -0500 Subject: [PATCH] feat(drug-reference): tabbed redesign with grouped search, multi-select situations, and a disclaimer gate (#1137) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * 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) * 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) * 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) * 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) * 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) * 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) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../drug-reference/DrugDisclaimerModal.tsx | 93 ++ .../drug-reference/DrugResultRow.tsx | 60 +- .../drug-reference/IngredientGroup.tsx | 73 + admin/inertia/layouts/AppLayout.tsx | 43 +- admin/inertia/pages/conditions/show.tsx | 2 +- admin/inertia/pages/drug-reference/index.tsx | 1218 +++++++++-------- .../pages/drug-reference/interactions.tsx | 6 +- admin/inertia/pages/drug-reference/show.tsx | 2 +- admin/inertia/pages/home.tsx | 15 +- 9 files changed, 894 insertions(+), 618 deletions(-) create mode 100644 admin/inertia/components/drug-reference/DrugDisclaimerModal.tsx create mode 100644 admin/inertia/components/drug-reference/IngredientGroup.tsx diff --git a/admin/inertia/components/drug-reference/DrugDisclaimerModal.tsx b/admin/inertia/components/drug-reference/DrugDisclaimerModal.tsx new file mode 100644 index 0000000..a3a7268 --- /dev/null +++ b/admin/inertia/components/drug-reference/DrugDisclaimerModal.tsx @@ -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 ( + {}} className="relative z-50"> + +
+
+ +
+ + + + + Before you use the Drug Reference + +
+ +
+

+ This tool shows general health information from official FDA drug labels and + matches symptoms to over-the-counter options. It is provided for information only. +

+
    +
  • + It is not medical advice and not a substitute for a doctor, pharmacist, or nurse. +
  • +
  • + It is not a drug-interaction checker. Always read each product’s full label + and check with a professional before combining medicines. +
  • +
  • + Situation matches come from label text, not clinical recommendations — they can be incomplete or + include products you wouldn’t expect. +
  • +
  • + Always follow the directions on the actual product you have; dosages and warnings + differ between products. +
  • +
  • + In an emergency, or if symptoms are severe, worsening, or you’re unsure,{' '} + contact a medical professional or call emergency services. +
  • +
+

+ Data is from openFDA (U.S. FDA, public domain). NOMAD is not affiliated with or endorsed by the FDA. +

+
+ +
+ + I understand — continue + +
+
+
+
+
+ ) +} diff --git a/admin/inertia/components/drug-reference/DrugResultRow.tsx b/admin/inertia/components/drug-reference/DrugResultRow.tsx index 3ee817b..16ca043 100644 --- a/admin/inertia/components/drug-reference/DrugResultRow.tsx +++ b/admin/inertia/components/drug-reference/DrugResultRow.tsx @@ -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 (
- {result.brand_name ?? result.generic_name ?? 'Unknown'} + {headline} - {/* OTC / Rx badge */} {isRx && ( Rx @@ -39,7 +66,6 @@ export default function DrugResultRow({ result }: Props) { )} - {/* Collapsed labels count */} {result.labelCount > 1 && ( {result.labelCount} labels @@ -47,15 +73,15 @@ export default function DrugResultRow({ result }: Props) { )}
-
- {result.brand_name && result.generic_name && ( - {result.generic_name} - )} - {result.manufacturer && ( - {result.manufacturer} - )} - {result.route && {result.route}} -
+ {subParts.length > 0 && ( +
+ {subParts.map((p, i) => ( + + {p} + + ))} +
+ )} diff --git a/admin/inertia/components/drug-reference/IngredientGroup.tsx b/admin/inertia/components/drug-reference/IngredientGroup.tsx new file mode 100644 index 0000000..777a498 --- /dev/null +++ b/admin/inertia/components/drug-reference/IngredientGroup.tsx @@ -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 + } + + 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 ( +
+ + {open && ( +
+ {group.products.map((d) => ( + + ))} +
+ )} +
+ ) +} diff --git a/admin/inertia/layouts/AppLayout.tsx b/admin/inertia/layouts/AppLayout.tsx index c660d38..d0ac37e 100644 --- a/admin/inertia/layouts/AppLayout.tsx +++ b/admin/inertia/layouts/AppLayout.tsx @@ -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 }) {
{ window.location.pathname !== '/home' && ( - +

Back to Home

)}
router.visit('/home')} > - Project NOMAD Logo -

Command Center

+ Project NOMAD Logo +

+ Command Center +


{children}