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 (
+
+
setOpen((o) => !o)}
+ className="flex w-full items-center gap-2 px-4 py-3 text-left hover:bg-gray-50 transition-colors"
+ >
+
+ {group.label}
+ {anyOtc && (
+
+ OTC
+
+ )}
+ {anyRx && (
+
+ Rx
+
+ )}
+
+ {group.products.length} products
+
+
+ {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')}
>
-
-
Command Center
+
+
+ Command Center
+
{children}
diff --git a/admin/inertia/pages/conditions/show.tsx b/admin/inertia/pages/conditions/show.tsx
index 95153b2..920633b 100644
--- a/admin/inertia/pages/conditions/show.tsx
+++ b/admin/inertia/pages/conditions/show.tsx
@@ -29,7 +29,7 @@ export default function ConditionsShow({ condition, drugs, remedies, drugRowCoun
const noData = drugRowCount === 0
return (
-
+
diff --git a/admin/inertia/pages/drug-reference/index.tsx b/admin/inertia/pages/drug-reference/index.tsx
index 0efed4d..e2c1351 100644
--- a/admin/inertia/pages/drug-reference/index.tsx
+++ b/admin/inertia/pages/drug-reference/index.tsx
@@ -1,16 +1,25 @@
import { useState, useCallback, useRef, useMemo, useEffect } from 'react'
import { Head, Link, router } from '@inertiajs/react'
+import { TabGroup, TabList, Tab, TabPanels, TabPanel } from '@headlessui/react'
import AppLayout from '~/layouts/AppLayout'
import StyledButton from '~/components/StyledButton'
import DrugResultRow from '~/components/drug-reference/DrugResultRow'
+import IngredientGroup, { type IngredientGrouping } from '~/components/drug-reference/IngredientGroup'
+import DrugDisclaimerModal, { hasAcknowledgedDrugDisclaimer } from '~/components/drug-reference/DrugDisclaimerModal'
import IngestStatus from '~/components/drug-reference/IngestStatus'
import SafetyBanner from '~/components/conditions/SafetyBanner'
import RemedySafetyNote from '~/components/conditions/RemedySafetyNote'
-import { IconSearch, IconFirstAidKit, IconLeaf } from '@tabler/icons-react'
-import type {
- DrugSearchResult,
- DrugIngestStatus,
-} from '../../../types/drug_reference'
+import {
+ IconSearch,
+ IconFirstAidKit,
+ IconLeaf,
+ IconPill,
+ IconDatabase,
+ IconAdjustmentsHorizontal,
+ IconX,
+ IconChevronDown,
+} from '@tabler/icons-react'
+import type { DrugSearchResult, DrugIngestStatus } from '../../../types/drug_reference'
import type { ConditionSummary, ConditionDrugsResult, NaturalRemedy } from '../../../types/conditions'
import { remedySourceName } from '../../../util/conditions'
import { PRODUCT_TYPES } from '../../../types/drug_reference'
@@ -29,13 +38,7 @@ interface PageProps {
}
/**
- * Sentinel for the type filter's "Natural" pill. Not an FDA product_type — it
- * routes the search to the curated NCCIH herb list instead of drug_labels.
- */
-const NATURAL_FILTER = 'NATURAL'
-
-/**
- * Curated administration routes for the route filter — the common openFDA
+ * Curated administration routes for the "Form" filter — the common openFDA
* `route` values a field user actually reaches for. The column holds a
* comma-joined uppercase list, so the backend matches with LIKE.
*/
@@ -53,25 +56,25 @@ const ROUTE_OPTIONS = [
'DENTAL',
] as const
-/** Title-case a route value for display (ORAL → Oral). */
-function routeLabel(r: string): string {
- return r.charAt(0) + r.slice(1).toLowerCase()
-}
-
-/** Case-insensitive remedy match on name / common names / uses. */
-function matchRemedies(remedies: NaturalRemedy[], query: string): NaturalRemedy[] {
- const q = query.trim().toLowerCase()
- if (!q) return []
- return remedies.filter(
- (r) =>
- r.name.toLowerCase().includes(q) ||
- r.commonNames.some((cn) => cn.toLowerCase().includes(q)) ||
- r.uses.toLowerCase().includes(q)
- )
+/** Friendly "form" label for a route value (how you take it), plainer than the raw route. */
+const ROUTE_FRIENDLY: Record
= {
+ ORAL: 'Oral (pill, liquid)',
+ TOPICAL: 'Topical (cream, gel)',
+ OPHTHALMIC: 'Eye drops',
+ OTIC: 'Ear drops',
+ NASAL: 'Nasal spray',
+ INHALATION: 'Inhaler',
+ SUBLINGUAL: 'Under the tongue',
+ RECTAL: 'Rectal',
+ VAGINAL: 'Vaginal',
+ TRANSDERMAL: 'Skin patch',
+ DENTAL: 'Dental',
}
const DEBOUNCE_MS = 350
const LIMIT = 50
+/** Max drugs shown per situation card (ranked, so the useful ones are on top). */
+const PER_SITUATION_DISPLAY = 25
/** Shared elevated-card surface for the result and detail panels. */
const CARD_SURFACE =
@@ -84,73 +87,122 @@ function initialSituationSlug(): string | null {
return new URLSearchParams(window.location.search).get('situation')
}
-/**
- * Match a free-text query to a curated situation by slug or label (case-insensitive:
- * exact first, then "contains"). Returns the matched summary or null. The curated
- * `searchTerms` are server-only, so off-list queries fall through to free-text on the
- * API — which still resolves a situation against the FULLTEXT index.
- */
-function matchSituation(query: string, conditions: ConditionSummary[]): ConditionSummary | null {
- const q = query.trim().toLowerCase()
- if (!q) return null
- const exact = conditions.find((c) => c.slug.toLowerCase() === q || c.label.toLowerCase() === q)
- if (exact) return exact
- return (
- conditions.find(
- (c) => c.label.toLowerCase().includes(q) || c.slug.replace(/-/g, ' ').includes(q)
- ) ?? null
- )
-}
-
/** Stable key for a collapsed drug result (brand+generic identity). */
function drugKey(d: DrugSearchResult): string {
return `${(d.brand_name ?? '').toLowerCase()}|${(d.generic_name ?? '').toLowerCase()}`
}
+/** Normalised active-ingredient group key (order/case-insensitive so combos group cleanly). */
+function ingredientKey(d: DrugSearchResult): string {
+ return (d.generic_name ?? 'Other')
+ .split(',')
+ .map((s) => s.trim().toUpperCase())
+ .filter(Boolean)
+ .sort()
+ .join(', ')
+}
+
+/** How many active ingredients a product lists (homeopathics carry many). */
+function ingredientCount(d: DrugSearchResult): number {
+ return Math.max(1, (d.generic_name ?? '').split(',').filter((s) => s.trim()).length)
+}
+
/**
- * Unified Drug Reference surface.
- *
- * One search box runs BOTH directions of the symbiotic relationship:
- * - drug-name search (a drug → identity) → "Drugs" section
- * - situation matching (a situation → its drugs) → "For …" section
- * Curated situation chips are always visible for browsing; clicking one searches it.
- *
- * Empty state (rowCount === 0): the "download FDA drug data" prompt + IngestStatus.
- * Once data is loaded: chips + dual-section results, with the FDA-data update control
- * and source citation at the foot.
+ * Rank situation matches so simple, single-ingredient OTC drugs (ibuprofen,
+ * acetaminophen) come before the many-ingredient homeopathic products the raw
+ * FDA indication match floods in. Not a medical judgement — just "fewer active
+ * ingredients first", the same principle as the drug-search grouping. Relevance
+ * order is preserved within a tier.
*/
-export default function DrugReferenceIndex({ ingestStatus, rowCount, conditions, remedies = [], remediesEnabled = false }: PageProps) {
+function rankSituationDrugs(drugs: DrugSearchResult[]): DrugSearchResult[] {
+ return drugs
+ .map((d, i) => ({ d, i }))
+ .sort((a, b) => {
+ const diff = ingredientCount(a.d) - ingredientCount(b.d)
+ return diff !== 0 ? diff : a.i - b.i
+ })
+ .map((x) => x.d)
+}
+
+/**
+ * Group collapsed drug results by active ingredient, single-ingredient groups
+ * first (P2 — the recognizable thing a user typed), then combination products.
+ * Within a rank tier, larger groups (more products) come first.
+ */
+function groupByIngredient(results: DrugSearchResult[]): IngredientGrouping[] {
+ const map = new Map()
+ for (const d of results) {
+ const key = ingredientKey(d)
+ let g = map.get(key)
+ if (!g) {
+ const parts = key.split(', ')
+ const label = parts
+ .map((p) => (p ? p.charAt(0) + p.slice(1).toLowerCase() : p))
+ .join(' + ')
+ g = { key, label: label || 'Other', single: parts.length <= 1, products: [] }
+ map.set(key, g)
+ }
+ g.products.push(d)
+ }
+ return Array.from(map.values()).sort((a, b) => {
+ if (a.single !== b.single) return a.single ? -1 : 1
+ return b.products.length - a.products.length
+ })
+}
+
+/**
+ * Unified Drug Reference surface — three tabs:
+ * 1. Search by drug — name search, results grouped by active ingredient.
+ * 2. By situation — multi-select symptoms → intersection + per-situation groups.
+ * 3. FDA data — download/ingest status + source.
+ *
+ * Empty state (rowCount === 0) keeps the prominent "download FDA drug data"
+ * prompt; the tabs only appear once data is installed.
+ */
+export default function DrugReferenceIndex({
+ ingestStatus,
+ rowCount,
+ conditions,
+ remediesEnabled = false,
+}: PageProps) {
+ // ── Tab 1: drug-name search ────────────────────────────────────────────────
const [query, setQuery] = useState('')
const [productType, setProductType] = useState(null)
const [route, setRoute] = useState(null)
const [sort, setSort] = useState<'relevance' | 'name'>('relevance')
- const [remedyKind, setRemedyKind] = useState<'all' | 'herb' | 'self-care'>('all')
-
- // Drug-name results.
const [drugResults, setDrugResults] = useState([])
const [drugLoading, setDrugLoading] = useState(false)
const [offset, setOffset] = useState(0)
const [hasMore, setHasMore] = useState(false)
+ const [drugSearched, setDrugSearched] = useState(false)
+ const [filtersOpen, setFiltersOpen] = useState(false)
+ const debounceRef = useRef | null>(null)
- // Situation results.
- const [situation, setSituation] = useState(null)
- const [situationDrugs, setSituationDrugs] = useState([])
- const [situationRemedies, setSituationRemedies] = useState([])
- const [situationLoading, setSituationLoading] = useState(false)
-
- const [searched, setSearched] = useState(false)
- const [error, setError] = useState(null)
+ // ── Tab 2: situations (multi-select) ───────────────────────────────────────
+ const [selectedSlugs, setSelectedSlugs] = useState([])
+ const [sitResults, setSitResults] = useState<
+ Record
+ >({})
+ const [browseOpen, setBrowseOpen] = useState(true)
+ // ── Tab 3 / ingest ─────────────────────────────────────────────────────────
const [triggering, setTriggering] = useState(false)
const [ingesting, setIngesting] = useState(false)
const [resetting, setResetting] = useState(false)
const [status, setStatus] = useState(ingestStatus)
- const debounceRef = useRef | null>(null)
- // Top-level phase derived from the two sub-phases. `busy` = a phase is running.
+ const [error, setError] = useState(null)
+ const [tabIndex, setTabIndex] = useState(0)
+
+ // First-open disclaimer gate (per-browser, localStorage). Checked after mount
+ // to avoid an SSR/hydration mismatch on window.localStorage.
+ const [showDisclaimer, setShowDisclaimer] = useState(false)
+ useEffect(() => {
+ if (!hasAcknowledgedDrugDisclaimer()) setShowDisclaimer(true)
+ }, [])
+
const phase = status?.phase ?? 'idle'
const busy = phase === 'downloading' || phase === 'ingesting'
- // The manual "Ingest into search" button is available once parts are on disk.
const canIngestFromDisk =
status?.download.state === 'completed' && status?.ingest.state !== 'running'
@@ -165,7 +217,7 @@ export default function DrugReferenceIndex({ ingestStatus, rowCount, conditions,
return Array.from(map.entries())
}, [conditions])
- /** Drug-name search (one direction). Appends on "Load more". */
+ // ── Drug-name search ────────────────────────────────────────────────────────
const searchDrugs = useCallback(
async (
q: string,
@@ -175,9 +227,7 @@ export default function DrugReferenceIndex({ ingestStatus, rowCount, conditions,
off: number,
append: boolean
) => {
- // The Natural pill routes the by-name direction to the curated herb list
- // (client-side, see remedyMatches) — drug_labels isn't queried at all.
- if (pt === NATURAL_FILTER || !q.trim()) {
+ if (!q.trim()) {
setDrugResults([])
setHasMore(false)
return
@@ -203,137 +253,141 @@ export default function DrugReferenceIndex({ ingestStatus, rowCount, conditions,
[]
)
- /**
- * Situation search (the other direction). Resolves the query to a curated
- * situation (by slug) or free text, fetches that situation's OTC drugs and
- * natural remedies (Phase 2).
- * Pass an explicit slug (chip / deep link) to force the curated path.
- * opts.route and opts.sort are forwarded to the backend so the situation drug
- * stack respects the active filter controls.
- */
- const searchSituation = useCallback(
- async (
- q: string,
- conds: ConditionSummary[],
- forceSlug?: string,
- opts?: { route: string | null; sort: 'relevance' | 'name' }
- ) => {
- const matched = forceSlug
- ? conds.find((c) => c.slug === forceSlug) ?? null
- : matchSituation(q, conds)
- if (!q.trim() && !forceSlug) {
- setSituation(null)
- setSituationDrugs([])
- setSituationRemedies([])
- return
- }
- setSituationLoading(true)
- try {
- const params = new URLSearchParams()
- if (matched) params.set('slug', matched.slug)
- else params.set('q', q)
- if (opts?.route) params.set('route', opts.route)
- if (opts?.sort && opts.sort !== 'relevance') params.set('sort', opts.sort)
- const resp = await fetch(`/api/conditions/drugs?${params}`)
- if (!resp.ok) throw new Error(`Search failed: HTTP ${resp.status}`)
- const json = (await resp.json()) as ConditionDrugsResult
- setSituation(json.condition ?? matched ?? { slug: '', label: q.trim(), category: 'Search' })
- setSituationDrugs(json.drugs ?? [])
- setSituationRemedies(json.remedies ?? [])
- } catch (err) {
- setError(err instanceof Error ? err.message : 'Search failed')
- } finally {
- setSituationLoading(false)
- }
- },
- []
- )
-
- /** Run both directions for a query. */
- const runSearch = useCallback(
+ const runDrugSearch = useCallback(
(q: string, pt: string | null, rt: string | null, srt: 'relevance' | 'name') => {
setError(null)
setOffset(0)
- setSearched(q.trim().length > 0)
+ setDrugSearched(q.trim().length > 0)
searchDrugs(q, pt, rt, srt, 0, false)
- searchSituation(q, conditions, undefined, { route: rt, sort: srt })
},
- [conditions, searchDrugs, searchSituation]
+ [searchDrugs]
)
const handleQueryChange = (e: React.ChangeEvent) => {
const val = e.target.value
setQuery(val)
if (debounceRef.current) clearTimeout(debounceRef.current)
- debounceRef.current = setTimeout(() => runSearch(val, productType, route, sort), DEBOUNCE_MS)
+ debounceRef.current = setTimeout(() => runDrugSearch(val, productType, route, sort), DEBOUNCE_MS)
}
-
const handleFilterChange = (pt: string | null) => {
setProductType(pt)
- setOffset(0)
- if (debounceRef.current) clearTimeout(debounceRef.current)
- // Product-type filter only narrows the drug-name section.
- searchDrugs(query, pt, route, sort, 0, false)
+ runDrugSearch(query, pt, route, sort)
}
-
const handleRouteChange = (rt: string | null) => {
setRoute(rt)
- setOffset(0)
- if (debounceRef.current) clearTimeout(debounceRef.current)
- searchDrugs(query, productType, rt, sort, 0, false)
- // Re-fetch the active situation with the new route filter.
- if (situation) {
- const forceSlug = situation.slug || undefined
- searchSituation(query, conditions, forceSlug, { route: rt, sort })
- }
+ runDrugSearch(query, productType, rt, sort)
}
-
const handleSortChange = (srt: 'relevance' | 'name') => {
setSort(srt)
- setOffset(0)
- if (debounceRef.current) clearTimeout(debounceRef.current)
- searchDrugs(query, productType, route, srt, 0, false)
- // Re-fetch the active situation with the new sort order.
- if (situation) {
- const forceSlug = situation.slug || undefined
- searchSituation(query, conditions, forceSlug, { route, sort: srt })
- }
+ runDrugSearch(query, productType, route, srt)
}
-
- /** Click a chip (or follow a deep link): set the box and search that situation. */
- const selectSituation = useCallback(
- (c: ConditionSummary) => {
- if (debounceRef.current) clearTimeout(debounceRef.current)
- setQuery(c.label)
- setError(null)
- setOffset(0)
- setSearched(true)
- searchDrugs(c.label, productType, route, sort, 0, false)
- searchSituation(c.label, conditions, c.slug)
- },
- [conditions, productType, route, sort, searchDrugs, searchSituation]
- )
-
- // Honor a ?situation= deep link from the drug-detail reverse link, once.
- useEffect(() => {
- const slug = initialSituationSlug()
- if (!slug) return
- const target = conditions.find((c) => c.slug === slug)
- if (target) selectSituation(target)
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [])
-
const handleLoadMore = () => {
const newOffset = offset + LIMIT
setOffset(newOffset)
searchDrugs(query, productType, route, sort, newOffset, true)
}
+ const ingredientGroups = useMemo(() => groupByIngredient(drugResults), [drugResults])
+ const drugNothing = drugSearched && !drugLoading && drugResults.length === 0
+
+ // Auto-collapse the filter drawer once results land (keeps controls on top, tidy).
+ useEffect(() => {
+ if (drugResults.length > 0) setFiltersOpen(false)
+ }, [drugResults.length > 0])
+
+ // ── Situation search (multi-select) ─────────────────────────────────────────
+ const fetchSituation = useCallback(
+ async (c: ConditionSummary, rt: string | null, srt: 'relevance' | 'name') => {
+ setSitResults((prev) => ({
+ ...prev,
+ [c.slug]: { label: c.label, drugs: prev[c.slug]?.drugs ?? [], remedies: prev[c.slug]?.remedies ?? [], loading: true },
+ }))
+ try {
+ // Pull a wide result set (200) and rank single-ingredient drugs first, so
+ // real OTC options surface above the homeopathic flood — and so the
+ // cross-situation intersection can actually find the common drugs.
+ const params = new URLSearchParams({ slug: c.slug, limit: '200' })
+ if (rt) params.set('route', rt)
+ if (srt && srt !== 'relevance') params.set('sort', srt)
+ const resp = await fetch(`/api/conditions/drugs?${params}`)
+ if (!resp.ok) throw new Error(`Search failed: HTTP ${resp.status}`)
+ const json = (await resp.json()) as ConditionDrugsResult
+ setSitResults((prev) => ({
+ ...prev,
+ [c.slug]: {
+ label: c.label,
+ drugs: rankSituationDrugs(json.drugs ?? []),
+ remedies: json.remedies ?? [],
+ loading: false,
+ },
+ }))
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Search failed')
+ setSitResults((prev) => ({ ...prev, [c.slug]: { label: c.label, drugs: [], remedies: [], loading: false } }))
+ }
+ },
+ []
+ )
+
+ const toggleSituation = useCallback(
+ (c: ConditionSummary) => {
+ setError(null)
+ setSelectedSlugs((prev) => {
+ if (prev.includes(c.slug)) return prev.filter((s) => s !== c.slug)
+ return [...prev, c.slug]
+ })
+ if (!selectedSlugs.includes(c.slug)) fetchSituation(c, route, sort)
+ },
+ [selectedSlugs, fetchSituation, route, sort]
+ )
+
+ // Re-fetch selected situations when route/sort change.
+ useEffect(() => {
+ for (const slug of selectedSlugs) {
+ const c = conditions.find((x) => x.slug === slug)
+ if (c) fetchSituation(c, route, sort)
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [route, sort])
+
+ // Honor a ?situation= deep link — jump to the situation tab and select it.
+ useEffect(() => {
+ const slug = initialSituationSlug()
+ if (!slug) return
+ const target = conditions.find((c) => c.slug === slug)
+ if (target) {
+ setTabIndex(1)
+ setSelectedSlugs([target.slug])
+ fetchSituation(target, null, 'relevance')
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [])
+
+ // Intersection ("treats all of these") — drugs present in EVERY selected situation.
+ const intersection = useMemo(() => {
+ const ready = selectedSlugs.map((s) => sitResults[s]).filter((r) => r && !r.loading)
+ if (ready.length < 2 || ready.length !== selectedSlugs.length) return []
+ const lists = ready.map((r) => r!.drugs)
+ if (lists.some((l) => l.length === 0)) return []
+ const [first, ...rest] = lists
+ const keySets = rest.map((l) => new Set(l.map(drugKey)))
+ return first.filter((d) => keySets.every((ks) => ks.has(drugKey(d))))
+ }, [selectedSlugs, sitResults])
+ const intersectionKeys = useMemo(() => new Set(intersection.map(drugKey)), [intersection])
+
+ const anySituationSelected = selectedSlugs.length > 0
+ const situationLoading = selectedSlugs.some((s) => sitResults[s]?.loading)
+
+ // Auto-collapse the browse list once a situation is picked.
+ useEffect(() => {
+ if (selectedSlugs.length > 0) setBrowseOpen(false)
+ }, [selectedSlugs.length > 0])
+
+ // ── Ingest handlers ─────────────────────────────────────────────────────────
const refreshStatus = async () => {
const statusResp = await fetch('/api/drug-reference/status')
if (statusResp.ok) setStatus(await statusResp.json())
}
-
const handleTriggerDownload = async () => {
if (triggering) return
setTriggering(true)
@@ -342,12 +396,11 @@ export default function DrugReferenceIndex({ ingestStatus, rowCount, conditions,
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
await refreshStatus()
} catch {
- // ignore — status will update on next poll
+ // status will update on next poll
} finally {
setTriggering(false)
}
}
-
const handleIngestFromDisk = async () => {
if (ingesting) return
setIngesting(true)
@@ -356,16 +409,11 @@ export default function DrugReferenceIndex({ ingestStatus, rowCount, conditions,
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
await refreshStatus()
} catch {
- // ignore — status will update on next poll
+ // status will update on next poll
} finally {
setIngesting(false)
}
}
-
- // Escape hatch for a wedged ingest: a worker killed mid-ingest (e.g. during an
- // upgrade) can leave the job 'active' with a stale lock, which disables the
- // normal buttons ("Indexing…") until lockDuration elapses. This force-clears
- // that job and restarts ingest from the on-disk parts (no re-download).
const handleResetIngest = async () => {
if (resetting) return
if (
@@ -382,12 +430,11 @@ export default function DrugReferenceIndex({ ingestStatus, rowCount, conditions,
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
await refreshStatus()
} catch {
- // ignore — status will update on next poll
+ // status will update on next poll
} finally {
setResetting(false)
}
}
-
const handleStatusRefresh = async () => {
try {
const resp = await fetch('/api/drug-reference/status')
@@ -404,95 +451,23 @@ export default function DrugReferenceIndex({ ingestStatus, rowCount, conditions,
}
const isEmpty = rowCount === 0
- const hasQuery = query.trim().length > 0
- const loading = drugLoading || situationLoading
- // Dedupe: a drug shown in the situation section is suppressed from the drug-name
- // section so the same product never appears twice on one screen.
- const situationKeys = useMemo(
- () => new Set(situationDrugs.map(drugKey)),
- [situationDrugs]
- )
- const dedupedDrugResults = useMemo(
- () => drugResults.filter((d) => !situationKeys.has(drugKey(d))),
- [drugResults, situationKeys]
- )
-
- // Remedy matches for the by-name direction. With the Natural pill active an
- // empty box browses the whole curated list; with a query it narrows by
- // name/common-names/uses. Under Rx/OTC the user asked for drugs specifically,
- // so the remedy block stays out of the way.
- const remedyMatches = useMemo(() => {
- let base: NaturalRemedy[]
- if (productType === NATURAL_FILTER) {
- base = query.trim() ? matchRemedies(remedies, query) : remedies
- } else if (productType === null) {
- base = matchRemedies(remedies, query)
- } else {
- return []
- }
- if (remedyKind === 'all') return base
- return base.filter((r) => (r.kind ?? 'herb') === remedyKind)
- }, [remedies, query, productType, remedyKind])
-
- // When the Natural pill is active, hide the OTC drugs sub-section in the
- // situation card (user asked for herbs/self-care only).
- const visibleSituationDrugs = productType === NATURAL_FILTER ? [] : situationDrugs
-
- // When a specific remedyKind filter is active, narrow the situation remedies too.
- // Show remedies only when Natural pill (NATURAL_FILTER) or All-types (null).
- const visibleSituationRemedies = useMemo(() => {
- if (productType !== null && productType !== NATURAL_FILTER) return []
- if (remedyKind === 'all') return situationRemedies
- return situationRemedies.filter((r) => (r.kind ?? 'herb') === remedyKind)
- }, [situationRemedies, productType, remedyKind])
-
- const showSituationSection =
- situation !== null && (visibleSituationDrugs.length > 0 || visibleSituationRemedies.length > 0)
- const showDrugSection = dedupedDrugResults.length > 0
- const showRemedySection = remedyMatches.length > 0
- const nothingFound =
- searched && !loading && !showSituationSection && !showDrugSection && !showRemedySection
-
- return (
-
-
-
-
- {/* Header */}
-
-
-
Drug Reference
- {rowCount > 0 && (
-
- {}}>
- Compare interactions
-
-
- )}
-
-
- Search a drug by name, or a situation — burn, fever, diarrhea — to see the
- over-the-counter drugs whose offline FDA labels treat it.{' '}
- {rowCount > 0 ? `${rowCount.toLocaleString()} labels.` : ''}
-
-
-
- {isEmpty ? (
- // ── Empty state ────────────────────────────────────────────────────
+ // ── Empty state (no tabs until data is installed) ──────────────────────────
+ if (isEmpty) {
+ return (
+
+
+
+
+
setShowDisclaimer(false)} />
No FDA drug data yet
Download the openFDA drug-label dataset to enable offline search. Requires ~1.7 GB
compressed download (~8–10 GB after ingestion).
-
-
+
{phase === 'downloading'
? 'Downloading…'
: phase === 'ingesting'
@@ -501,347 +476,442 @@ export default function DrugReferenceIndex({ ingestStatus, rowCount, conditions,
? 'Starting…'
: 'Download FDA drug data'}
-
{canIngestFromDisk && (
-
+
{ingesting ? 'Starting…' : 'Ingest into search'}
)}
-
- {/* Escape hatch — only while ingest appears to be running. Clears a
- wedged "Indexing…" state (stale active job from a killed worker)
- and restarts from the already-downloaded files. */}
{phase === 'ingesting' && (
-
+
{resetting ? 'Restarting…' : 'Restart ingest'}
)}
-
{status && (
)}
- ) : (
- // ── Unified search surface ─────────────────────────────────────────
- <>
- {/* Search box */}
-
-
-
+
+
+
+ )
+ }
+
+ const tabClass = ({ selected }: { selected: boolean }) =>
+ `flex items-center gap-2 rounded-t-lg border-b-2 px-4 py-2.5 text-sm font-semibold transition-colors focus:outline-none ${
+ selected
+ ? 'border-desert-green text-desert-green-darker'
+ : 'border-transparent text-desert-stone hover:text-desert-green-darker hover:border-desert-stone-lighter'
+ }`
+
+ return (
+
+
+
+
+
setShowDisclaimer(false)} />
+
+
+
+
+ Search by drug
+
+
+ By situation
+
+
+ FDA data
+
+
+
+ {error && (
+
+ {error}
+ )}
- {/* OTC / Rx filter pills — narrow the by-name drug section */}
-
-
handleFilterChange(null)}>
- All
-
-
handleFilterChange(PRODUCT_TYPES.OTC)}
- >
- OTC
-
-
handleFilterChange(PRODUCT_TYPES.RX)}
- >
- Rx
-
- {/* Affirmative-content gate (#1040): the "Natural" remedy filter
- only appears once remedies are enabled (post clinician-pass). */}
- {remediesEnabled && (
-
handleFilterChange(NATURAL_FILTER)}
- >
- Natural
-
- )}
-
- {/* Secondary controls: route + sort for the drug-name search, or
- herb/self-care sub-filter when Natural is active. */}
- {productType === NATURAL_FILTER ? (
-
- {(['all', 'herb', 'self-care'] as const).map((k) => (
- setRemedyKind(k)}>
- {k === 'all' ? 'All kinds' : k === 'herb' ? 'Herbs' : 'Self-care'}
-
- ))}
-
- ) : (
-
- handleRouteChange(e.target.value || null)}
- className="rounded-lg border border-desert-stone-lighter bg-white px-2 py-1 text-xs text-desert-green-darker focus:border-desert-green focus:outline-none"
- aria-label="Filter by administration route"
- >
- Any route
- {ROUTE_OPTIONS.map((r) => (
-
- {routeLabel(r)}
-
- ))}
-
- handleSortChange(e.target.value as 'relevance' | 'name')}
- className="rounded-lg border border-desert-stone-lighter bg-white px-2 py-1 text-xs text-desert-green-darker focus:border-desert-green focus:outline-none"
- aria-label="Sort drug results"
- >
- Best match
- A–Z
-
-
- )}
-
-
- {error && (
-
- {error}
+
+ {/* ══ Tab 1: Search by drug ══════════════════════════════════════ */}
+
+ {/* Controls (always on top) */}
+
+
+
- )}
- {loading && !showSituationSection && !showDrugSection && (
- Searching…
- )}
-
- {/* ── Situation section (a situation → its drugs + remedies) ──────── */}
- {showSituationSection && (
-
- {/* Section header */}
-
-
-
-
-
- For{' '}
-
- “{situation?.label}”
-
-
-
- {visibleSituationDrugs.length > 0 && `${visibleSituationDrugs.length} OTC`}
- {visibleSituationDrugs.length > 0 && visibleSituationRemedies.length > 0 && ' · '}
- {visibleSituationRemedies.length > 0 && `${visibleSituationRemedies.length} natural`}
-
+ {/* Collapsible filter drawer */}
+
+
+ handleFilterChange(null)}>
+ All
+
+ handleFilterChange(PRODUCT_TYPES.OTC)}
+ >
+ Over-the-counter
+
+ handleFilterChange(PRODUCT_TYPES.RX)}
+ >
+ Prescription
+
+ setFiltersOpen((o) => !o)}
+ className="ml-auto flex items-center gap-1 rounded-full border border-desert-stone-lighter bg-white px-3 py-1 text-xs text-desert-green-darker hover:border-desert-green"
+ >
+
+ {route ? ROUTE_FRIENDLY[route] : 'Form'} · {sort === 'name' ? 'A–Z' : 'Best match'}
+
+
+ {filtersOpen && (
+
+
+ Form (how you take it)
+ handleRouteChange(e.target.value || null)}
+ className="rounded-lg border border-desert-stone-lighter bg-white px-2 py-1 text-xs text-desert-green-darker focus:border-desert-green focus:outline-none"
+ >
+ Any form
+ {ROUTE_OPTIONS.map((r) => (
+
+ {ROUTE_FRIENDLY[r]}
+
+ ))}
+
+
+
+ Sort
+ handleSortChange(e.target.value as 'relevance' | 'name')}
+ className="rounded-lg border border-desert-stone-lighter bg-white px-2 py-1 text-xs text-desert-green-darker focus:border-desert-green focus:outline-none"
+ >
+ Best match
+ A–Z
+
+
+
+ )}
+
- {/* OTC drugs sub-section */}
- {visibleSituationDrugs.length > 0 && (
- <>
- {visibleSituationRemedies.length > 0 && (
-
-
- Over-the-counter options
-
+ {drugLoading && drugResults.length === 0 && (
+
Searching…
+ )}
+
+ {!drugSearched && (
+
+ Type a medicine name above to search {rowCount.toLocaleString()} FDA drug labels.
+
+ Looking for something to treat a symptom instead? Try the{' '}
+ setTabIndex(1)}>
+ By situation
+ {' '}
+ tab.
+
+ )}
+
+ {ingredientGroups.length > 0 && (
+
+
+
+
+
+
+ {ingredientGroups.length} ingredient{ingredientGroups.length !== 1 ? 's' : ''}
+
+
+ {drugResults.length} product{drugResults.length !== 1 ? 's' : ''}
+
+
+
+ {ingredientGroups.map((g, i) => (
+
+ ))}
+
+ {hasMore && (
+
+
+ {drugLoading ? 'Loading…' : 'Load more products'}
+
+
+ )}
+
+ )}
+
+ {drugNothing && (
+
+ No medicines match “{query}”.
+
+ )}
+
+
+ {/* ══ Tab 2: By situation ════════════════════════════════════════ */}
+
+
+
+ {/* Selected situations + collapsible browser (controls on top) */}
+
+ {anySituationSelected && (
+
+ Selected:
+ {selectedSlugs.map((slug) => {
+ const c = conditions.find((x) => x.slug === slug)
+ if (!c) return null
+ return (
+ toggleSituation(c)}
+ className="flex items-center gap-1 rounded-full border border-desert-olive bg-desert-olive px-3 py-1 text-sm text-white"
+ >
+ {c.label}
+
+
+ )
+ })}
+ {
+ setSelectedSlugs([])
+ setBrowseOpen(true)
+ }}
+ className="text-xs text-desert-stone underline hover:text-desert-green-darker"
+ >
+ Clear
+
+
+ )}
+
+
setBrowseOpen((o) => !o)}
+ className="flex items-center gap-1 rounded-full border border-desert-stone-lighter bg-white px-3 py-1 text-xs text-desert-green-darker hover:border-desert-olive"
+ >
+
+ {anySituationSelected ? 'Add another situation' : 'Pick one or more situations'}
+
+
+
+ {browseOpen && (
+
+ {grouped.map(([category, items]) => (
+
+
+ {category}
+
+
+ {items.map((c) => {
+ const active = selectedSlugs.includes(c.slug)
+ return (
+ toggleSituation(c)}
+ className={`rounded-full border px-3 py-1 text-sm transition-colors ${
+ active
+ ? 'border-desert-olive bg-desert-olive text-white'
+ : 'border-desert-stone-lighter bg-white text-desert-green-darker hover:border-desert-olive hover:bg-desert-olive/5'
+ }`}
+ >
+ {c.label}
+
+ )
+ })}
+
+
+ ))}
+
+ )}
+
+
+ {!anySituationSelected && (
+
+ Pick a situation (or a few) above to see the over-the-counter drugs whose FDA labels list it.
+
+ )}
+
+ {situationLoading && (
+ Finding options…
+ )}
+
+ {/* Intersection — treats ALL selected situations */}
+ {intersection.length > 0 && (
+
+
+
+
+
+
+ Treats all {selectedSlugs.length} selected
+
+ {intersection.length} OTC
+
+
+ {intersection.map((d) => (
+
+ ))}
+
+
+ )}
+
+ {/* Per-situation groups — shown only as a fallback when nothing treats
+ ALL selected (intersection empty), or for a single situation. */}
+ {!situationLoading && intersection.length === 0 && selectedSlugs.length >= 2 && (
+
+ No single option treats all {selectedSlugs.length} of these — here are options for each
+ situation.
+
+ )}
+ {!situationLoading &&
+ intersection.length === 0 &&
+ selectedSlugs.map((slug) => {
+ const r = sitResults[slug]
+ if (!r || r.loading) return null
+ const allDrugs = r.drugs.filter((d) => !intersectionKeys.has(drugKey(d)))
+ const drugs = allDrugs.slice(0, PER_SITUATION_DISPLAY)
+ const remediesShown = remediesEnabled ? r.remedies : []
+ if (allDrugs.length === 0 && remediesShown.length === 0) {
+ return (
+
+ No additional OTC options found for {r.label} .
+
+ )
+ }
+ return (
+
+
+
+
+
+
+ For “{r.label}”
+
+
+ {allDrugs.length} OTC
+ {allDrugs.length > PER_SITUATION_DISPLAY ? ` · top ${PER_SITUATION_DISPLAY}` : ''}
+
+
+ {drugs.length > 0 && (
+
+ {drugs.map((d) => (
+
+ ))}
)}
-
- {visibleSituationDrugs.map((d) => (
-
- ))}
-
- >
- )}
+ {remediesShown.length > 0 && (
+
+
+
+
+ Natural remedies
+
+
+
+
+
+
+ {remediesShown.map((rem) => (
+
+ ))}
+
+
+ )}
+
+ )
+ })}
+
- {/* Natural remedies sub-section */}
- {visibleSituationRemedies.length > 0 && (
-
-
-
-
-
-
- Natural remedies
-
-
-
-
-
-
- {visibleSituationRemedies.map((r) => (
-
- ))}
-
-
- )}
-
- )}
-
- {/* ── Drug-name section (a drug → identity) ─────────────────────── */}
- {showDrugSection && (
-
-
-
-
-
-
Drugs
-
- {dedupedDrugResults.length} match
- {dedupedDrugResults.length !== 1 ? 'es' : ''}
-
-
-
- {dedupedDrugResults.map((d) => (
-
- ))}
-
- {hasMore && (
-
-
- {drugLoading ? 'Loading…' : 'Load more'}
+ {/* ══ Tab 3: FDA data ════════════════════════════════════════════ */}
+
+
+
+
FDA drug data
+
+ {canIngestFromDisk && (
+
+ {ingesting ? 'Starting…' : 'Ingest into search'}
+
+ )}
+
+ {phase === 'downloading'
+ ? 'Downloading…'
+ : phase === 'ingesting'
+ ? 'Indexing…'
+ : 'Update FDA data'}
- )}
-
- )}
-
- {/* ── Natural remedies by name (or full browse on the Natural pill) ── */}
- {showRemedySection && (
-
-
-
-
-
-
Natural remedies
-
- {remedyMatches.length} match{remedyMatches.length !== 1 ? 'es' : ''}
-
-
-
-
-
- {remedyMatches.map((r) => (
-
- ))}
-
-
- )}
-
- {nothingFound && (
-
- No drugs, remedies, or situations match “{query}”. Try a situation below.
-
- )}
-
- {/* ── Browse: curated situation chips (always visible) ──────────── */}
-
-
-
-
-
-
-
Browse by situation
-
-
- {hasQuery
- ? 'Or pick another situation to see its over-the-counter options.'
- : 'Pick a situation to see the over-the-counter drugs whose FDA labels list it.'}
+
+ {rowCount.toLocaleString()} drug labels installed. Updating re-checks openFDA for a newer
+ dataset and refreshes the offline copy.
+ {status &&
}
+
+
+
-
-
- {grouped.map(([category, items]) => (
-
-
- {category}
-
-
- {items.map((c) => {
- const active = situation?.slug === c.slug
- return (
- selectSituation(c)}
- className={`rounded-full border px-3 py-1 text-sm transition-colors ${
- active
- ? 'border-desert-olive bg-desert-olive text-white'
- : 'border-desert-stone-lighter bg-white text-desert-green-darker hover:border-desert-olive hover:bg-desert-olive/5'
- }`}
- >
- {c.label}
-
- )
- })}
-
-
- ))}
-
-
-
- {/* ── FDA data update control ───────────────────────────────────── */}
-
-
-
FDA data
-
- {canIngestFromDisk && (
-
- {ingesting ? 'Starting…' : 'Ingest into search'}
-
- )}
-
- {phase === 'downloading'
- ? 'Downloading…'
- : phase === 'ingesting'
- ? 'Indexing…'
- : 'Update FDA data'}
-
-
-
- {status &&
}
-
- >
- )}
-
- {/* ── Source citation (CC0, no-endorsement) ───────────────────────── */}
-
- Source: U.S. Food & Drug Administration drug labeling, via{' '}
- openFDA — public domain (CC0 1.0). NOMAD is not affiliated with or
- endorsed by the FDA. Label data and situation matches are label-text only; do not rely on
- them for medical decisions.
-
+
)
}
-// ─── Situation remedy row (compact, inside the situation card) ────────────────
+// ─── Page header ──────────────────────────────────────────────────────────────
+
+function PageHeader({ rowCount }: { rowCount: number }) {
+ return (
+
+
+
Drug Reference
+ {rowCount > 0 && (
+
+ {}}>
+ Compare label warnings
+
+
+ )}
+
+
+ Look up a medicine by name, or start from a symptom to find over-the-counter options — all from
+ offline FDA drug labels.
+
+
+ )
+}
+
+// ─── Source citation ──────────────────────────────────────────────────────────
+
+function SourceFooter() {
+ return (
+
+ Source: U.S. Food & Drug Administration drug labeling, via{' '}
+ openFDA — public domain (CC0 1.0). NOMAD is not affiliated with or endorsed by the
+ FDA. Label data and situation matches are label-text only; do not rely on them for medical decisions.
+
+ )
+}
+
+// ─── Situation remedy row ─────────────────────────────────────────────────────
function SituationRemedyRow({ remedy }: { remedy: NaturalRemedy }) {
return (
@@ -858,8 +928,6 @@ function SituationRemedyRow({ remedy }: { remedy: NaturalRemedy }) {
{remedy.commonNames.join(', ')}
)}
- {/* Plain-text attribution — deliberately NOT a link. The card is fully
- self-contained for offline use; nothing on it needs internet. */}
Source: {remedySourceName(remedy)}
@@ -909,9 +977,7 @@ function FilterPill({
type="button"
onClick={onClick}
className={`rounded-full border px-3 py-1 text-sm transition-colors ${
- active
- ? activeClass
- : `border-desert-stone-lighter bg-white text-desert-green-darker ${hoverClass}`
+ active ? activeClass : `border-desert-stone-lighter bg-white text-desert-green-darker ${hoverClass}`
}`}
>
{children}
diff --git a/admin/inertia/pages/drug-reference/interactions.tsx b/admin/inertia/pages/drug-reference/interactions.tsx
index 691e279..ea72cca 100644
--- a/admin/inertia/pages/drug-reference/interactions.tsx
+++ b/admin/inertia/pages/drug-reference/interactions.tsx
@@ -121,8 +121,8 @@ export default function DrugReferenceInteractions({ ingestStatus, rowCount }: Pa
const atMax = selectedIds.length >= MAX_COMPARE
return (
-
-
+
+
{/* Back nav */}
@@ -135,7 +135,7 @@ export default function DrugReferenceInteractions({ ingestStatus, rowCount }: Pa
-
Compare Drug Interactions
+
Compare label warnings
View each drug's FDA-labeled interaction warnings side by side. Select up to {MAX_COMPARE} drugs.
diff --git a/admin/inertia/pages/drug-reference/show.tsx b/admin/inertia/pages/drug-reference/show.tsx
index 8e07e33..db33699 100644
--- a/admin/inertia/pages/drug-reference/show.tsx
+++ b/admin/inertia/pages/drug-reference/show.tsx
@@ -24,7 +24,7 @@ export default function DrugReferenceShow({ label, situations = [] }: PageProps)
const isOtc = label.product_type === PRODUCT_TYPES.OTC
return (
-
+
diff --git a/admin/inertia/pages/home.tsx b/admin/inertia/pages/home.tsx
index 8311dcb..cf3f158 100644
--- a/admin/inertia/pages/home.tsx
+++ b/admin/inertia/pages/home.tsx
@@ -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: ,
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: ,
- 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