mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-10-17 13:19:18 +02:00
* feat(drug-reference): offline FDA drug labels, conditions, and remedies
Adds an offline medical-reference feature with three coupled layers:
- Drug Reference: full-text search over openFDA drug-label indications,
a detail page per label, and a side-by-side single-drug comparison view.
A two-phase background pipeline downloads the openFDA label parts to the
storage volume (resumable) and ingests them into the search table.
- Conditions ("When to use what"): a curated spine of first-aid situations
that maps each situation to matching OTC drugs, each linking back to its
Drug Reference detail page.
- Curated remedies: hand-authored natural and home-remedy entries drawn from
US-government public-domain sources (NCCIH, CDC, MedlinePlus, FDA), shown
with their source links and the same safety disclaimers as the rest of the
feature.
The drug-reference and conditions layers are intentionally coupled: the drug
detail page shows the situations a drug treats, and the conditions controller
reads the same drug_labels table.
Safety surfaces ship as written. The amber SafetyBanner ("informational only,
not medical advice, not an FDA endorsement, not a drug-interaction checker, in
an emergency call emergency services") renders on the condition pages and the
search page; the detail and comparison pages carry their own "not a cross-drug
interaction checker" callout; and every page carries the openFDA CC0 source
citation and no-FDA-affiliation footer.
Wiring on this branch:
- start/routes.ts: the /drug-reference and /conditions page GETs plus their
/api/* groups.
- commands/queue/work.ts: the drug-download and drug-ingest queues, both at
concurrency 1. The two drug queues get a per-queue stall override
(lockDuration 1_800_000, maxStalledCount 3) because each part is one long
stream; every other queue keeps the existing 300000 default.
- inertia/pages/home.tsx: Drug Reference and "When to use what" tiles. The
icon and display_order are a starting point, open to change.
- types/kv_store.ts: the two drugReference.* keys the pipeline reads and writes.
- package.json: yauzl and stream-json (plus their @types), used by the ingest
job to stream the label JSON out of the downloaded zips.
The app reads the conditions and remedy data from the compiled TS constants in
app/data/; the repo-root collections/*.json files are the browseable mirrors.
The natural-remedies standalone test reads collections/natural_remedies.json to
assert the two stay in sync, so that file is also a test fixture.
The four standalone tests pass (drug_interactions, drug_ingest_status,
conditions, natural_remedies). tsc reports no errors in the feature code.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore(drug-reference): drop a fork-internal comment reference
* fix(drug-reference): address review — defer FULLTEXT, fallback remedies prop, strip fork refs
- migration: wrap the FULLTEXT ALTER in this.defer so it runs after the
deferred createTable (was silently swallowed, index never created)
- controller: add remedies:[] to the index() error fallback (required prop)
- strip fork-internal issue/spec references from ported comments
- tsconfig: exclude tests/standalone (node --experimental-strip-types only)
- correct the varchar(768) byte-math comment; extend remedy-spine test
* fix(drug-reference): make the interaction comparison readable at five drugs
The comparison view laid its columns out on an equal-fraction CSS grid
(repeat(N, minmax(0, 1fr))), so each added drug shrank every column; at the
five-drug maximum the FDA interaction text was squeezed into unreadable slivers.
Lay the columns out with flex instead: full-width and stacked on phones, then
fixed-width columns that scroll sideways from the sm: breakpoint up, so they
never shrink below a readable width. Theme the columns with the same palette as
the rest of the page (they were on stock gray), and give the headers a fixed
min-height so columns line up when drug names wrap.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(collections): route a 'dataset' tier resource to the drug pipeline
Add an optional `type` discriminator to SpecResource ('zim' | 'dataset',
absent == 'zim'), so the tier installer can carry a DB-ingested resource
alongside ZIM files. ZimService.downloadCategoryTier branches on it: a
'dataset' resource dispatches the existing FDA download+ingest pipeline
instead of RunDownloadJob, guarded against duplicate dispatch by the drug
ingest status. Every existing manifest entry has no `type` and keeps the
exact ZIM path.
Widen InstalledResource.resource_type to include 'dataset' and exclude
dataset rows from the ZIM/map catalog-update scan (datasets aren't
filename-versioned; their freshness path is separate). No dataset rows are
written yet: the InstalledResource 'dataset' row on ingest-ready, the
manifest entry, install-gating, and the downloads-aggregator integration
are follow-up commits.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(drug-reference): rework into an opt-in medicine-standard tier dataset
Reshapes the offline FDA drug reference from a built-in feature into curated
content installed by selecting the Medicine / Standard tier, per maintainer
direction.
- install-state: the ingest writes an installed_resources 'dataset' row on
ready (version = the openFDA export_date), threaded installer to download to
ingest; the tier-status math and the home-tile gate read it. Manual ingests
write no row, so install-state stays tied to the curated path.
- manifest: declare the dataset in the medicine-standard tier (runtime fetches
the remote manifest, so this also needs to land upstream).
- install-gating: the drug-reference home tiles render only when installed.
- uninstall: DrugReferenceService.uninstall() stops the two drug queues, deletes
the on-disk parts, truncates drug_labels (schema kept), clears the KV markers,
and drops the install row. Best-effort, logged, scoped to drug data only.
- downloads: the download phase reports the canonical {percent, downloadedBytes,
totalBytes} shape as one drug-data card in the Active Downloads aggregator with
cancel/remove; the heavy ingest stays in the IngestStatus surface with an
Indexing handoff on the card.
- auto-update: a daily DrugAutoUpdateJob compares the manifest export_date and
re-downloads when newer, gated on installed + no active job.
typecheck clean; the drug standalone suites pass. Three points are flagged in
code for the maintainer: the InstalledResource 'dataset' approach, the tier
home, and the export_date string format.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(downloads): keep the drug download card live across all parts
The Active Downloads card filtered to the deterministic jobId, but the
download's continuations run under auto-generated jobIds (only part 0 uses the
deterministic one). So the card tracked part 0 and then vanished while parts
2..N kept downloading. The queue is concurrency 1, so collapse to whichever
single part is in flight and report the deterministic jobId: one card tracks
aggregate progress through the whole download and cancel/remove still routes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(drug-reference): co-locate a persistent safety note with affirmative remedy guidance
Add RemedySafetyNote at the head of every natural-remedy section — the two on
Drug Reference and the one on "When to use what" — so the "informational only,
not medical advice, seek real medical care in an emergency" framing appears with
the guidance itself, not only in the page-top banner. Replaces the terse
per-section caveat with the same amber alert language as SafetyBanner.
Addresses the upstream #1040 review request that the disclaimer be unmistakable
and present wherever affirmative self-care guidance appears, not a one-time banner.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(drug-reference): fold dataset freshness into the content-auto-update path
The drug dataset auto-updated on its own daily cron that ignored the
contentAutoUpdate.* master switch, so it would refresh even with content
auto-update turned off, and it didn't ride the content-update path the way
ZIMs and maps do.
Move the export_date freshness/apply orchestration onto
DrugReferenceService.attemptAutoUpdate(), add
ContentAutoUpdateService.attemptDrugDataset() gated on the same enabled +
window config, and have the hourly ContentAutoUpdateJob drive both. Retire
the standalone DrugAutoUpdateJob. The ZIM/map attempt() path is unchanged.
Addresses the upstream #1040 request to wire the openFDA export_date check
into the content updater so the dataset updates alongside ZIMs and maps.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(drug-reference): strengthen the remedy note to consult a clinician before combining with meds
Widen the affirmative-remedy safety note from "talk to a clinician before use"
to explicitly cover using a remedy AND combining one with a medication the user
already takes — the interaction case is the higher-risk path for an off-grid
user self-treating.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(drug-reference): gate affirmative remedy content behind an off-by-default flag
Add drugReference.remediesEnabled (default off), independent of the tier
install. When off, the server emits no remedy data at any boundary — the
drug-reference page prop, conditions show, and the /api/conditions/drugs
situation search — and the "Natural" filter is hidden, so installing the
medicine-standard tier lights up the verbatim FDA label search and the
condition-to-OTC matching but not the hand-authored self-care and herbal
sections. No user-facing toggle: it is flipped on after a clinician content-pass.
Implements the upstream #1040 split-by-risk request: the regulated label content
ships with the tier; the authored remedy guidance stays gated until sign-off.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Jake Turner <52841588+jakeaturner@users.noreply.github.com>
295 lines
11 KiB
TypeScript
295 lines
11 KiB
TypeScript
/**
|
||
* Standalone gate test for the condition-spine pure helpers.
|
||
*
|
||
* Japa cannot boot locally without MySQL/Redis, so this file exercises the pure
|
||
* helpers (parseConditionsFile, parseConditionEntry, buildIndicationQuery,
|
||
* orderOtcFirst, findConditionBySlug, toConditionSummary) directly under
|
||
* `node --experimental-strip-types`. Run:
|
||
* node --experimental-strip-types tests/standalone/conditions.standalone.ts
|
||
*
|
||
* Also smoke-checks the shipped curated spine (admin/app/data/conditions.ts)
|
||
* parses cleanly and stays a bounded curated list of ~30–50 conditions.
|
||
*/
|
||
import assert from 'node:assert/strict'
|
||
import {
|
||
parseConditionsFile,
|
||
parseConditionEntry,
|
||
buildIndicationQuery,
|
||
orderOtcFirst,
|
||
findConditionBySlug,
|
||
toConditionSummary,
|
||
situationsForIndications,
|
||
OTC_PRODUCT_TYPE,
|
||
RX_PRODUCT_TYPE,
|
||
} from '../../util/conditions.ts'
|
||
import { CONDITIONS_FILE } from '../../app/data/conditions.ts'
|
||
import { PRODUCT_TYPES } from '../../types/drug_reference.ts'
|
||
import type { DrugSearchResult } from '../../types/drug_reference.ts'
|
||
|
||
let passed = 0
|
||
function check(name: string, fn: () => void) {
|
||
fn()
|
||
passed++
|
||
console.log(` ok - ${name}`)
|
||
}
|
||
|
||
function makeDrug(id: number, product_type: string | null): DrugSearchResult {
|
||
return {
|
||
id,
|
||
brand_name: `Brand ${id}`,
|
||
generic_name: `generic ${id}`,
|
||
manufacturer: null,
|
||
route: null,
|
||
product_type,
|
||
labelCount: 1,
|
||
}
|
||
}
|
||
|
||
// ── parseConditionEntry ───────────────────────────────────────────────────────
|
||
check('parseConditionEntry accepts a well-formed entry', () => {
|
||
const entry = parseConditionEntry({
|
||
slug: 'burns',
|
||
label: 'Burns',
|
||
category: 'Skin & wounds',
|
||
searchTerms: ['burns', 'sunburn'],
|
||
})
|
||
assert.ok(entry)
|
||
assert.equal(entry!.slug, 'burns')
|
||
assert.deepEqual(entry!.searchTerms, ['burns', 'sunburn'])
|
||
})
|
||
|
||
check('parseConditionEntry de-dupes searchTerms case-insensitively', () => {
|
||
const entry = parseConditionEntry({
|
||
slug: 's',
|
||
label: 'L',
|
||
category: 'C',
|
||
searchTerms: ['Burn', 'burn', ' burn ', 'scald'],
|
||
})
|
||
assert.ok(entry)
|
||
assert.deepEqual(entry!.searchTerms, ['Burn', 'scald'])
|
||
})
|
||
|
||
check('parseConditionEntry rejects missing slug/label/category', () => {
|
||
assert.equal(parseConditionEntry({ label: 'L', category: 'C', searchTerms: ['x'] }), null)
|
||
assert.equal(parseConditionEntry({ slug: 's', category: 'C', searchTerms: ['x'] }), null)
|
||
assert.equal(parseConditionEntry({ slug: 's', label: 'L', searchTerms: ['x'] }), null)
|
||
})
|
||
|
||
check('parseConditionEntry rejects an entry with no usable searchTerms', () => {
|
||
assert.equal(parseConditionEntry({ slug: 's', label: 'L', category: 'C', searchTerms: [] }), null)
|
||
assert.equal(
|
||
parseConditionEntry({ slug: 's', label: 'L', category: 'C', searchTerms: ['', ' '] }),
|
||
null
|
||
)
|
||
})
|
||
|
||
check('parseConditionEntry rejects non-objects', () => {
|
||
assert.equal(parseConditionEntry(null), null)
|
||
assert.equal(parseConditionEntry('burns'), null)
|
||
assert.equal(parseConditionEntry(42), null)
|
||
})
|
||
|
||
// ── parseConditionsFile ───────────────────────────────────────────────────────
|
||
check('parseConditionsFile parses a valid file', () => {
|
||
const file = parseConditionsFile({
|
||
version: '2026-06-07',
|
||
conditions: [
|
||
{ slug: 'a', label: 'A', category: 'C', searchTerms: ['a'] },
|
||
{ slug: 'b', label: 'B', category: 'C', searchTerms: ['b'] },
|
||
],
|
||
})
|
||
assert.equal(file.version, '2026-06-07')
|
||
assert.equal(file.conditions.length, 2)
|
||
})
|
||
|
||
check('parseConditionsFile drops malformed entries but keeps good ones', () => {
|
||
const file = parseConditionsFile({
|
||
version: 'v',
|
||
conditions: [
|
||
{ slug: 'good', label: 'G', category: 'C', searchTerms: ['g'] },
|
||
{ slug: 'bad-no-terms', label: 'B', category: 'C', searchTerms: [] },
|
||
'not-an-object',
|
||
],
|
||
})
|
||
assert.equal(file.conditions.length, 1)
|
||
assert.equal(file.conditions[0].slug, 'good')
|
||
})
|
||
|
||
check('parseConditionsFile de-dupes by slug, keeping the first', () => {
|
||
const file = parseConditionsFile({
|
||
version: 'v',
|
||
conditions: [
|
||
{ slug: 'dup', label: 'First', category: 'C', searchTerms: ['x'] },
|
||
{ slug: 'dup', label: 'Second', category: 'C', searchTerms: ['y'] },
|
||
],
|
||
})
|
||
assert.equal(file.conditions.length, 1)
|
||
assert.equal(file.conditions[0].label, 'First')
|
||
})
|
||
|
||
check('parseConditionsFile fail-soft on garbage input', () => {
|
||
assert.deepEqual(parseConditionsFile(null), { version: 'unknown', conditions: [] })
|
||
assert.deepEqual(parseConditionsFile('nope'), { version: 'unknown', conditions: [] })
|
||
assert.deepEqual(parseConditionsFile({ version: 'v' }), { version: 'v', conditions: [] })
|
||
})
|
||
|
||
// ── buildIndicationQuery ──────────────────────────────────────────────────────
|
||
check('buildIndicationQuery quotes multi-word phrases, passes single words bare', () => {
|
||
assert.equal(buildIndicationQuery(['burn', 'sore throat']), 'burn "sore throat"')
|
||
})
|
||
|
||
check('buildIndicationQuery de-dupes and drops empties', () => {
|
||
assert.equal(buildIndicationQuery(['burn', 'BURN', '', ' ', 'scald']), 'burn scald')
|
||
})
|
||
|
||
check('buildIndicationQuery strips embedded quotes', () => {
|
||
assert.equal(buildIndicationQuery(['sore "throat"']), '"sore throat"')
|
||
})
|
||
|
||
check('buildIndicationQuery returns empty string for all-empty input', () => {
|
||
assert.equal(buildIndicationQuery([]), '')
|
||
assert.equal(buildIndicationQuery(['', ' ']), '')
|
||
})
|
||
|
||
// ── product-type constants drift-guard ───────────────────────────────────────
|
||
check('helper OTC/RX constants match canonical PRODUCT_TYPES', () => {
|
||
assert.equal(OTC_PRODUCT_TYPE, PRODUCT_TYPES.OTC)
|
||
assert.equal(RX_PRODUCT_TYPE, PRODUCT_TYPES.RX)
|
||
})
|
||
|
||
// ── orderOtcFirst ─────────────────────────────────────────────────────────────
|
||
check('orderOtcFirst buckets OTC → other → Rx, preserving relevance order', () => {
|
||
const input = [
|
||
makeDrug(1, PRODUCT_TYPES.RX),
|
||
makeDrug(2, PRODUCT_TYPES.OTC),
|
||
makeDrug(3, null),
|
||
makeDrug(4, PRODUCT_TYPES.OTC),
|
||
makeDrug(5, PRODUCT_TYPES.RX),
|
||
]
|
||
const out = orderOtcFirst(input)
|
||
assert.deepEqual(
|
||
out.map((d) => d.id),
|
||
[2, 4, 3, 1, 5]
|
||
)
|
||
})
|
||
|
||
check('orderOtcFirst does not mutate the input array', () => {
|
||
const input = [makeDrug(1, PRODUCT_TYPES.RX), makeDrug(2, PRODUCT_TYPES.OTC)]
|
||
const snapshot = input.map((d) => d.id)
|
||
orderOtcFirst(input)
|
||
assert.deepEqual(
|
||
input.map((d) => d.id),
|
||
snapshot
|
||
)
|
||
})
|
||
|
||
// ── findConditionBySlug / toConditionSummary ─────────────────────────────────
|
||
check('findConditionBySlug returns the matching condition or null', () => {
|
||
const conditions = parseConditionsFile({
|
||
version: 'v',
|
||
conditions: [{ slug: 'fever', label: 'Fever', category: 'C', searchTerms: ['fever'] }],
|
||
}).conditions
|
||
assert.equal(findConditionBySlug(conditions, 'fever')!.label, 'Fever')
|
||
assert.equal(findConditionBySlug(conditions, 'nope'), null)
|
||
assert.equal(findConditionBySlug(conditions, ''), null)
|
||
})
|
||
|
||
check('toConditionSummary strips searchTerms', () => {
|
||
const summary = toConditionSummary({
|
||
slug: 'fever',
|
||
label: 'Fever',
|
||
category: 'Pain',
|
||
searchTerms: ['fever', 'reduces fever'],
|
||
})
|
||
assert.deepEqual(summary, { slug: 'fever', label: 'Fever', category: 'Pain' })
|
||
assert.equal('searchTerms' in summary, false)
|
||
})
|
||
|
||
// ── situationsForIndications (reverse link) ───────────────────────────────────
|
||
const sampleConditions = parseConditionsFile({
|
||
version: 'v',
|
||
conditions: [
|
||
{ slug: 'fever', label: 'Fever', category: 'Pain', searchTerms: ['fever', 'reduces fever'] },
|
||
{ slug: 'pain', label: 'Pain', category: 'Pain', searchTerms: ['pain', 'minor aches'] },
|
||
{ slug: 'cough', label: 'Cough', category: 'Cold', searchTerms: ['cough', 'cough suppressant'] },
|
||
{
|
||
slug: 'sore-throat',
|
||
label: 'Sore throat',
|
||
category: 'Cold',
|
||
searchTerms: ['sore throat', 'throat pain'],
|
||
},
|
||
],
|
||
}).conditions
|
||
|
||
check('situationsForIndications matches a single-word term, case-insensitively', () => {
|
||
const out = situationsForIndications('Temporarily reduces FEVER and relieves minor aches', sampleConditions)
|
||
assert.deepEqual(
|
||
out.map((c) => c.slug),
|
||
['fever', 'pain']
|
||
)
|
||
})
|
||
|
||
check('situationsForIndications matches a multi-word phrase term', () => {
|
||
const out = situationsForIndications('For the temporary relief of sore throat', sampleConditions)
|
||
assert.deepEqual(
|
||
out.map((c) => c.slug),
|
||
['sore-throat']
|
||
)
|
||
})
|
||
|
||
check('situationsForIndications preserves curated order, not text order', () => {
|
||
// text mentions cough first, then fever, but curated order is fever before cough
|
||
const out = situationsForIndications('Controls cough; also reduces fever', sampleConditions)
|
||
assert.deepEqual(
|
||
out.map((c) => c.slug),
|
||
['fever', 'cough']
|
||
)
|
||
})
|
||
|
||
check('situationsForIndications returns summaries (no searchTerms leak)', () => {
|
||
const out = situationsForIndications('pain', sampleConditions)
|
||
assert.equal(out.length, 1)
|
||
assert.equal('searchTerms' in out[0], false)
|
||
assert.deepEqual(out[0], { slug: 'pain', label: 'Pain', category: 'Pain' })
|
||
})
|
||
|
||
check('situationsForIndications returns [] for blank/empty/null input', () => {
|
||
assert.deepEqual(situationsForIndications('', sampleConditions), [])
|
||
assert.deepEqual(situationsForIndications(' ', sampleConditions), [])
|
||
assert.deepEqual(situationsForIndications(null, sampleConditions), [])
|
||
assert.deepEqual(situationsForIndications(undefined, sampleConditions), [])
|
||
})
|
||
|
||
check('situationsForIndications returns [] when nothing matches', () => {
|
||
assert.deepEqual(situationsForIndications('antiseptic for minor cuts', sampleConditions), [])
|
||
})
|
||
|
||
check('situationsForIndications dedupes by slug (one hit per condition)', () => {
|
||
// both "pain" and "minor aches" appear; pain must surface exactly once
|
||
const out = situationsForIndications('relieves minor aches and pain', sampleConditions)
|
||
assert.equal(out.filter((c) => c.slug === 'pain').length, 1)
|
||
})
|
||
|
||
// ── shipped spine smoke check ─────────────────────────────────────────────────
|
||
check('shipped CONDITIONS_FILE parses cleanly with no dropped entries', () => {
|
||
const parsed = parseConditionsFile(CONDITIONS_FILE)
|
||
assert.equal(parsed.conditions.length, CONDITIONS_FILE.conditions.length)
|
||
})
|
||
|
||
check('shipped spine holds a bounded curated list of ~30–50 conditions', () => {
|
||
const n = CONDITIONS_FILE.conditions.length
|
||
assert.ok(n >= 30 && n <= 50, `expected 30–50 conditions, got ${n}`)
|
||
})
|
||
|
||
check('shipped spine has unique slugs and non-empty searchTerms', () => {
|
||
const slugs = new Set<string>()
|
||
for (const c of CONDITIONS_FILE.conditions) {
|
||
assert.ok(!slugs.has(c.slug), `duplicate slug: ${c.slug}`)
|
||
slugs.add(c.slug)
|
||
assert.ok(c.searchTerms.length > 0, `empty searchTerms: ${c.slug}`)
|
||
assert.ok(buildIndicationQuery(c.searchTerms).length > 0, `empty query: ${c.slug}`)
|
||
}
|
||
})
|
||
|
||
console.log(`\n${passed} checks passed`)
|