- The medicine database is now much bigger and has a real update path.
Added: Live source updater for RxNorm, DailyMed, and openFDA. Admin dashboard panel to refresh medicine sources. CLI updater for periodic jobs: [scripts/update_medicine_catalog.php](/Users/tyemeclifford/Documents/GH/workout/scripts/update_medicine_catalog.php) Update history table: medication_source_updates. Curl fallback for external fetches when PHP stream DNS fails. README instructions for periodic updates. I also ran the imports: Current medication catalog count: 6,002. Latest source imports:RxNorm: 3,875 inserted, 1,125 updated. openFDA: 912 inserted, 88 updated. DailyMed: 95 inserted. Periodic update command: /Users/tyemeclifford/frankenphp php-cli scripts/update_medicine_catalog.php 5000 all
This commit is contained in:
@@ -35,12 +35,13 @@ SQLite is created automatically at:
|
||||
data/health_tracker.sqlite
|
||||
```
|
||||
|
||||
The app seeds sample profiles, medical terminology, condition summaries, a medication catalog, NIH/NLM study-link categories, medication logs, vitals, labs, symptoms, allergies, appointments, immunizations, and the older optional wellness/fitness data.
|
||||
The app seeds sample profiles, medical terminology, condition summaries, an expanded medication catalog, NIH/NLM study-link categories, medication logs, vitals, labs, symptoms, allergies, appointments, immunizations, and the older optional wellness/fitness data.
|
||||
|
||||
## Included
|
||||
|
||||
- Multi-user profiles with separate records and theme preferences.
|
||||
- Admin dashboard for creating, editing, activating/deactivating, and deleting user profiles.
|
||||
- Admin medicine-source updater for refreshing the catalog from RxNorm, DailyMed, and openFDA.
|
||||
- First user created in a database is automatically promoted to `admin`; later users default to `member`.
|
||||
- Daily, weekly, and monthly views for medical logs.
|
||||
- Medication logging by date, dose, route, frequency, status, prescriber, and notes.
|
||||
@@ -48,14 +49,31 @@ The app seeds sample profiles, medical terminology, condition summaries, a medic
|
||||
- Lab result records with units, reference ranges, clinician/source notes, and collection dates.
|
||||
- Symptom journal, allergy list, appointments, and immunization records.
|
||||
- Medical terminology and condition database with source links.
|
||||
- Medicine catalog with RxNorm, MedlinePlus, PubMed, and ClinicalTrials.gov links.
|
||||
- Searchable medicine catalog by generic name, active ingredient, chemical name, common brand names, and generic/alternate names.
|
||||
- Medicine links to RxNorm, DailyMed, PubChem, MedlinePlus, PubMed, and ClinicalTrials.gov.
|
||||
- NIH/NLM research-link categories related to the listed medicines.
|
||||
- Neon dark theme by default, light mode option, and green, blue, red, pink, and orange accents.
|
||||
- Optional legacy fitness, nutrition, and supplement tracking remains available as wellness context.
|
||||
|
||||
## Medicine Catalog Updates
|
||||
|
||||
The catalog ships with a curated starter set, the current SQLite file includes thousands of imported medicine concepts, and it can be refreshed from public medicine sources.
|
||||
|
||||
From the admin dashboard, use the medicine source updater and choose `All sources`, `RxNorm`, `DailyMed`, or `openFDA`.
|
||||
|
||||
From the command line:
|
||||
|
||||
```sh
|
||||
/Users/tyemeclifford/frankenphp php-cli scripts/update_medicine_catalog.php 1000 all
|
||||
```
|
||||
|
||||
For a periodic cron-style update, run the same command nightly or weekly. The first argument is the max rows per source, from `25` to `5000`; the second argument is `all`, `RxNorm`, `DailyMed`, or `openFDA`.
|
||||
|
||||
## Reference Sources
|
||||
|
||||
- RxNorm and RxNav from the U.S. National Library of Medicine: `https://www.nlm.nih.gov/research/umls/rxnorm/index.html`
|
||||
- DailyMed label search from the U.S. National Library of Medicine: `https://dailymed.nlm.nih.gov/dailymed/`
|
||||
- PubChem chemical/compound search from NCBI: `https://pubchem.ncbi.nlm.nih.gov/`
|
||||
- MedlinePlus condition and medication pages: `https://medlineplus.gov/`
|
||||
- PubMed search links for medicine and condition research: `https://pubmed.ncbi.nlm.nih.gov/`
|
||||
- ClinicalTrials.gov search links for study discovery: `https://clinicaltrials.gov/`
|
||||
|
||||
@@ -23,6 +23,7 @@ function app_db(): PDO
|
||||
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
||||
$pdo->exec('PRAGMA foreign_keys = ON');
|
||||
$pdo->exec('PRAGMA busy_timeout = 5000');
|
||||
|
||||
app_init_db($pdo);
|
||||
|
||||
@@ -315,6 +316,16 @@ CREATE TABLE IF NOT EXISTS research_studies (
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS medication_source_updates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
source TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
imported_count INTEGER NOT NULL DEFAULT 0,
|
||||
updated_count INTEGER NOT NULL DEFAULT 0,
|
||||
message TEXT DEFAULT '',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_workouts_user_date ON workouts(user_id, scheduled_on);
|
||||
CREATE INDEX IF NOT EXISTS idx_nutrition_logs_user_date ON nutrition_logs(user_id, logged_on);
|
||||
CREATE INDEX IF NOT EXISTS idx_supplement_logs_user_date ON supplement_logs(user_id, taken_on);
|
||||
@@ -328,6 +339,7 @@ CREATE INDEX IF NOT EXISTS idx_appointments_user_date ON appointments(user_id, a
|
||||
CREATE INDEX IF NOT EXISTS idx_immunization_records_user_date ON immunization_records(user_id, administered_on);
|
||||
CREATE INDEX IF NOT EXISTS idx_medical_terms_term ON medical_terms(term);
|
||||
CREATE INDEX IF NOT EXISTS idx_medication_catalog_generic ON medication_catalog(generic_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_medication_source_updates_created ON medication_source_updates(created_at);
|
||||
SQL);
|
||||
|
||||
migrate_users_table($pdo);
|
||||
@@ -611,6 +623,12 @@ function seed_expanded_medication_catalog(PDO $pdo): void
|
||||
{
|
||||
$created = app_now();
|
||||
$rows = expanded_medication_catalog_rows();
|
||||
$existingCount = (int) $pdo->query('SELECT COUNT(*) FROM medication_catalog')->fetchColumn();
|
||||
$needsSearchText = (int) $pdo->query("SELECT COUNT(*) FROM medication_catalog WHERE search_terms IS NULL OR search_terms = ''")->fetchColumn();
|
||||
$needsChemicalNames = (int) $pdo->query("SELECT COUNT(*) FROM medication_catalog WHERE chemical_name IS NULL OR chemical_name = ''")->fetchColumn();
|
||||
if ($existingCount >= count($rows) && $needsSearchText === 0 && $needsChemicalNames === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$select = $pdo->prepare('SELECT id FROM medication_catalog WHERE lower(generic_name) = lower(?) LIMIT 1');
|
||||
$insert = $pdo->prepare(
|
||||
@@ -1705,6 +1723,312 @@ function library_search_payload(array $query): array
|
||||
];
|
||||
}
|
||||
|
||||
function http_json(string $url, int $timeout = 20): ?array
|
||||
{
|
||||
$context = stream_context_create([
|
||||
'http' => [
|
||||
'method' => 'GET',
|
||||
'timeout' => $timeout,
|
||||
'header' => "Accept: application/json\r\nUser-Agent: NeonMedicalTracker/1.0\r\n",
|
||||
],
|
||||
]);
|
||||
$body = @file_get_contents($url, false, $context);
|
||||
if (($body === false || $body === '') && function_exists('shell_exec')) {
|
||||
$curl = trim((string) shell_exec('command -v curl 2>/dev/null'));
|
||||
if ($curl === '' && is_executable('/usr/bin/curl')) {
|
||||
$curl = '/usr/bin/curl';
|
||||
}
|
||||
if ($curl !== '') {
|
||||
$command = escapeshellarg($curl)
|
||||
. ' -fsSL --max-time ' . (int) $timeout
|
||||
. ' -H ' . escapeshellarg('Accept: application/json')
|
||||
. ' -A ' . escapeshellarg('NeonMedicalTracker/1.0')
|
||||
. ' ' . escapeshellarg($url)
|
||||
. ' 2>/dev/null';
|
||||
$body = shell_exec($command);
|
||||
}
|
||||
}
|
||||
if (!is_string($body) || $body === '') {
|
||||
return null;
|
||||
}
|
||||
$json = json_decode($body, true);
|
||||
return is_array($json) ? $json : null;
|
||||
}
|
||||
|
||||
function medication_import_row(string $name, string $source, string $brandNames = '', string $className = 'Imported medicine concept'): array
|
||||
{
|
||||
$cleanName = preg_replace('/\s+/', ' ', trim($name));
|
||||
if ($cleanName === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$sourceLabel = match ($source) {
|
||||
'RxNorm' => 'RxNorm / NLM',
|
||||
'DailyMed' => 'DailyMed / NLM',
|
||||
'openFDA' => 'openFDA / FDA',
|
||||
default => $source,
|
||||
};
|
||||
|
||||
return [
|
||||
'generic_name' => strtolower($cleanName),
|
||||
'active_ingredient' => $cleanName,
|
||||
'chemical_name' => $cleanName,
|
||||
'brand_names' => $brandNames,
|
||||
'generic_brands' => $cleanName,
|
||||
'class_name' => $className,
|
||||
'drug_family' => 'Imported reference',
|
||||
'common_use' => "Imported from {$sourceLabel}. Review official labels and clinician guidance before use.",
|
||||
'tracking_note' => 'Use this entry as a reference/search starting point; verify dose, indication, and safety details with official labeling or a clinician.',
|
||||
'rxnorm_url' => medication_source_url('https://mor.nlm.nih.gov/RxNav/search?searchBy=String&searchTerm=', $cleanName),
|
||||
'medlineplus_url' => 'https://medlineplus.gov/druginformation.html',
|
||||
'pubchem_url' => medication_source_url('https://pubchem.ncbi.nlm.nih.gov/#query=', $cleanName),
|
||||
'dailymed_url' => medication_source_url('https://dailymed.nlm.nih.gov/dailymed/search.cfm?query=', $cleanName),
|
||||
'pubmed_url' => medication_source_url('https://pubmed.ncbi.nlm.nih.gov/?term=', $cleanName . ' medication'),
|
||||
'clinicaltrials_url' => medication_source_url('https://clinicaltrials.gov/search?term=', $cleanName),
|
||||
'source_name' => $sourceLabel,
|
||||
'search_terms' => implode(' ', [$cleanName, $brandNames, $className, $sourceLabel]),
|
||||
];
|
||||
}
|
||||
|
||||
function fetch_rxnorm_medication_rows(int $limit): array
|
||||
{
|
||||
$rows = [];
|
||||
foreach (['IN', 'PIN'] as $tty) {
|
||||
$json = http_json('https://rxnav.nlm.nih.gov/REST/allconcepts.json?tty=' . $tty);
|
||||
$concepts = $json['minConceptGroup']['minConcept'] ?? [];
|
||||
if (!is_array($concepts)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($concepts as $concept) {
|
||||
if (count($rows) >= $limit) {
|
||||
break 2;
|
||||
}
|
||||
$name = (string) ($concept['name'] ?? '');
|
||||
$row = medication_import_row($name, 'RxNorm');
|
||||
if ($row) {
|
||||
$rows[] = $row;
|
||||
}
|
||||
}
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
function fetch_openfda_medication_rows(int $limit): array
|
||||
{
|
||||
$url = 'https://api.fda.gov/drug/label.json?count=openfda.generic_name.exact&limit=' . $limit;
|
||||
$json = http_json($url);
|
||||
$results = $json['results'] ?? [];
|
||||
if (!is_array($results)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
foreach ($results as $result) {
|
||||
if (count($rows) >= $limit) {
|
||||
break;
|
||||
}
|
||||
$term = (string) ($result['term'] ?? '');
|
||||
$row = medication_import_row($term, 'openFDA');
|
||||
if ($row) {
|
||||
$rows[] = $row;
|
||||
}
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
function fetch_dailymed_medication_rows(int $limit): array
|
||||
{
|
||||
$url = 'https://dailymed.nlm.nih.gov/dailymed/services/v2/drugnames.json';
|
||||
$json = http_json($url);
|
||||
$data = $json['data'] ?? $json['drugNames'] ?? $json['results'] ?? [];
|
||||
if (!is_array($data)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
foreach ($data as $item) {
|
||||
if (count($rows) >= $limit) {
|
||||
break;
|
||||
}
|
||||
$name = is_array($item)
|
||||
? (string) ($item['drug_name'] ?? $item['name'] ?? $item['term'] ?? '')
|
||||
: (string) $item;
|
||||
$row = medication_import_row($name, 'DailyMed');
|
||||
if ($row) {
|
||||
$rows[] = $row;
|
||||
}
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
function merge_text_values(string $current, string $incoming): string
|
||||
{
|
||||
$parts = [];
|
||||
foreach (explode(',', $current . ',' . $incoming) as $part) {
|
||||
$value = trim($part);
|
||||
if ($value === '') {
|
||||
continue;
|
||||
}
|
||||
$parts[strtolower($value)] = $value;
|
||||
}
|
||||
return implode(', ', array_values($parts));
|
||||
}
|
||||
|
||||
function upsert_imported_medication_rows(PDO $pdo, array $rows): array
|
||||
{
|
||||
$created = app_now();
|
||||
$select = $pdo->prepare('SELECT * FROM medication_catalog WHERE lower(generic_name) = lower(?) LIMIT 1');
|
||||
$insert = $pdo->prepare(
|
||||
"INSERT INTO medication_catalog
|
||||
(generic_name, active_ingredient, chemical_name, brand_names, generic_brands, class_name, drug_family,
|
||||
common_use, tracking_note, rxnorm_url, medlineplus_url, pubchem_url, dailymed_url, pubmed_url,
|
||||
clinicaltrials_url, source_name, search_terms, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
$update = $pdo->prepare(
|
||||
"UPDATE medication_catalog
|
||||
SET active_ingredient = ?, chemical_name = ?, brand_names = ?, generic_brands = ?,
|
||||
class_name = ?, drug_family = ?, common_use = ?, tracking_note = ?, rxnorm_url = ?,
|
||||
medlineplus_url = ?, pubchem_url = ?, dailymed_url = ?, pubmed_url = ?,
|
||||
clinicaltrials_url = ?, source_name = ?, search_terms = ?
|
||||
WHERE id = ?"
|
||||
);
|
||||
|
||||
$seen = [];
|
||||
$inserted = 0;
|
||||
$updated = 0;
|
||||
foreach ($rows as $row) {
|
||||
if (!$row || empty($row['generic_name'])) {
|
||||
continue;
|
||||
}
|
||||
$key = strtolower((string) $row['generic_name']);
|
||||
if (isset($seen[$key])) {
|
||||
continue;
|
||||
}
|
||||
$seen[$key] = true;
|
||||
|
||||
$select->execute([$row['generic_name']]);
|
||||
$existing = $select->fetch();
|
||||
if (!$existing) {
|
||||
$insert->execute([
|
||||
$row['generic_name'],
|
||||
$row['active_ingredient'],
|
||||
$row['chemical_name'],
|
||||
$row['brand_names'],
|
||||
$row['generic_brands'],
|
||||
$row['class_name'],
|
||||
$row['drug_family'],
|
||||
$row['common_use'],
|
||||
$row['tracking_note'],
|
||||
$row['rxnorm_url'],
|
||||
$row['medlineplus_url'],
|
||||
$row['pubchem_url'],
|
||||
$row['dailymed_url'],
|
||||
$row['pubmed_url'],
|
||||
$row['clinicaltrials_url'],
|
||||
$row['source_name'],
|
||||
$row['search_terms'],
|
||||
$created,
|
||||
]);
|
||||
$inserted++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$next = [];
|
||||
foreach (['active_ingredient', 'chemical_name', 'class_name', 'drug_family', 'common_use', 'tracking_note', 'rxnorm_url', 'medlineplus_url', 'pubchem_url', 'dailymed_url', 'pubmed_url', 'clinicaltrials_url'] as $field) {
|
||||
$next[$field] = trim((string) ($existing[$field] ?? '')) !== '' ? $existing[$field] : $row[$field];
|
||||
}
|
||||
$next['brand_names'] = merge_text_values((string) ($existing['brand_names'] ?? ''), (string) $row['brand_names']);
|
||||
$next['generic_brands'] = merge_text_values((string) ($existing['generic_brands'] ?? ''), (string) $row['generic_brands']);
|
||||
$next['source_name'] = merge_text_values((string) ($existing['source_name'] ?? ''), (string) $row['source_name']);
|
||||
$next['search_terms'] = trim((string) ($existing['search_terms'] ?? '') . ' ' . (string) $row['search_terms']);
|
||||
|
||||
$update->execute([
|
||||
$next['active_ingredient'],
|
||||
$next['chemical_name'],
|
||||
$next['brand_names'],
|
||||
$next['generic_brands'],
|
||||
$next['class_name'],
|
||||
$next['drug_family'],
|
||||
$next['common_use'],
|
||||
$next['tracking_note'],
|
||||
$next['rxnorm_url'],
|
||||
$next['medlineplus_url'],
|
||||
$next['pubchem_url'],
|
||||
$next['dailymed_url'],
|
||||
$next['pubmed_url'],
|
||||
$next['clinicaltrials_url'],
|
||||
$next['source_name'],
|
||||
$next['search_terms'],
|
||||
(int) $existing['id'],
|
||||
]);
|
||||
$updated++;
|
||||
}
|
||||
|
||||
return ['inserted' => $inserted, 'updated' => $updated, 'seen' => count($seen)];
|
||||
}
|
||||
|
||||
function record_medication_source_update(PDO $pdo, string $source, string $status, int $inserted, int $updated, string $message): void
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
"INSERT INTO medication_source_updates
|
||||
(source, status, imported_count, updated_count, message, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
$stmt->execute([$source, $status, $inserted, $updated, $message, app_now()]);
|
||||
}
|
||||
|
||||
function refresh_medication_sources(array $data): array
|
||||
{
|
||||
$pdo = app_db();
|
||||
if (array_key_exists('acting_user_id', $data) || array_key_exists('user_id', $data)) {
|
||||
require_admin($pdo, $data);
|
||||
}
|
||||
|
||||
$limit = max(25, min(5000, int_value($data, 'limit', 1000)));
|
||||
$sources = [
|
||||
'RxNorm' => 'fetch_rxnorm_medication_rows',
|
||||
'DailyMed' => 'fetch_dailymed_medication_rows',
|
||||
'openFDA' => 'fetch_openfda_medication_rows',
|
||||
];
|
||||
$requestedSource = text_value($data, 'source', 'all');
|
||||
if ($requestedSource !== 'all') {
|
||||
$sources = array_intersect_key($sources, [$requestedSource => true]);
|
||||
}
|
||||
|
||||
$summary = [];
|
||||
$totalInserted = 0;
|
||||
$totalUpdated = 0;
|
||||
foreach ($sources as $source => $fetcher) {
|
||||
try {
|
||||
$rows = $fetcher($limit);
|
||||
if (!$rows) {
|
||||
record_medication_source_update($pdo, $source, 'empty', 0, 0, 'No rows returned. The source may be unavailable or rate-limited.');
|
||||
$summary[] = ['source' => $source, 'status' => 'empty', 'inserted' => 0, 'updated' => 0, 'message' => 'No rows returned.'];
|
||||
continue;
|
||||
}
|
||||
$result = upsert_imported_medication_rows($pdo, $rows);
|
||||
$totalInserted += $result['inserted'];
|
||||
$totalUpdated += $result['updated'];
|
||||
$message = "Fetched {$result['seen']} unique rows.";
|
||||
record_medication_source_update($pdo, $source, 'ok', $result['inserted'], $result['updated'], $message);
|
||||
$summary[] = ['source' => $source, 'status' => 'ok', 'inserted' => $result['inserted'], 'updated' => $result['updated'], 'message' => $message];
|
||||
} catch (Throwable $exception) {
|
||||
record_medication_source_update($pdo, $source, 'error', 0, 0, $exception->getMessage());
|
||||
$summary[] = ['source' => $source, 'status' => 'error', 'inserted' => 0, 'updated' => 0, 'message' => $exception->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'inserted' => $totalInserted,
|
||||
'updated' => $totalUpdated,
|
||||
'catalog_count' => (int) db_value($pdo, 'SELECT COUNT(*) FROM medication_catalog'),
|
||||
'sources' => $summary,
|
||||
'recent_updates' => db_all($pdo, 'SELECT * FROM medication_source_updates ORDER BY created_at DESC, id DESC LIMIT 10'),
|
||||
];
|
||||
}
|
||||
|
||||
function create_user(array $data): array
|
||||
{
|
||||
$pdo = app_db();
|
||||
|
||||
Binary file not shown.
@@ -31,6 +31,21 @@ require __DIR__ . '/partials/site-header.php';
|
||||
|
||||
<section class="admin-panel site-admin-panel" id="adminPanel" hidden>
|
||||
<div class="admin-stats" id="adminStats"></div>
|
||||
|
||||
<section class="admin-source-panel">
|
||||
<div class="site-section-heading">
|
||||
<p class="site-eyebrow">Medicine source updater</p>
|
||||
<h2>Refresh the medication catalog from public sources.</h2>
|
||||
<p>Pull medicine concepts from RxNorm, DailyMed, and openFDA. Use a smaller limit for quick checks or a larger limit for periodic maintenance.</p>
|
||||
</div>
|
||||
<form class="admin-source-form" id="medicineSourceRefreshForm">
|
||||
<label class="site-field"><span>Source</span><select name="source"><option value="all">All sources</option><option>RxNorm</option><option>DailyMed</option><option>openFDA</option></select></label>
|
||||
<label class="site-field"><span>Max rows per source</span><input name="limit" type="number" min="25" max="5000" step="25" value="1000"></label>
|
||||
<button class="site-primary" type="submit">Refresh medicines</button>
|
||||
</form>
|
||||
<div class="source-refresh-result" id="medicineSourceRefreshResult" role="status" aria-live="polite"></div>
|
||||
</section>
|
||||
|
||||
<div class="admin-layout">
|
||||
<div class="admin-user-list" id="adminUserList"></div>
|
||||
<form class="admin-editor" id="adminUserForm">
|
||||
|
||||
@@ -45,6 +45,7 @@ try {
|
||||
'create_allergy_record' => 'create_allergy_record',
|
||||
'create_appointment' => 'create_appointment',
|
||||
'create_immunization_record' => 'create_immunization_record',
|
||||
'refresh_medication_sources' => 'refresh_medication_sources',
|
||||
];
|
||||
|
||||
if (!isset($routes[$action])) {
|
||||
|
||||
@@ -186,6 +186,22 @@ function adminFormPayload(form) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
function renderRefreshResult(result) {
|
||||
const target = admin$("#medicineSourceRefreshResult");
|
||||
if (!target) return;
|
||||
const rows = (result.sources || []).map((source) => `
|
||||
<article>
|
||||
<strong>${adminEscape(source.source)}: ${adminEscape(source.status)}</strong>
|
||||
<span>${adminNumber(source.inserted)} inserted / ${adminNumber(source.updated)} updated</span>
|
||||
<small>${adminEscape(source.message || "")}</small>
|
||||
</article>
|
||||
`).join("");
|
||||
target.innerHTML = `
|
||||
<p>${adminNumber(result.catalog_count)} medicines in catalog after refresh.</p>
|
||||
<div class="source-refresh-list">${rows}</div>
|
||||
`;
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
admin$("#adminProfileSelect").addEventListener("change", async (event) => {
|
||||
adminState.userId = event.target.value;
|
||||
@@ -248,6 +264,27 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
const refreshForm = admin$("#medicineSourceRefreshForm");
|
||||
if (refreshForm) {
|
||||
refreshForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const payload = adminFormPayload(refreshForm);
|
||||
const button = refreshForm.querySelector("button[type='submit']");
|
||||
if (button) button.disabled = true;
|
||||
admin$("#medicineSourceRefreshResult").textContent = "Refreshing medicine sources...";
|
||||
try {
|
||||
const result = await adminApi("refresh_medication_sources", payload);
|
||||
renderRefreshResult(result);
|
||||
adminToast("Medicine sources refreshed");
|
||||
} catch (error) {
|
||||
admin$("#medicineSourceRefreshResult").textContent = error.message;
|
||||
adminToast(error.message);
|
||||
} finally {
|
||||
if (button) button.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await loadAdminState();
|
||||
} catch (error) {
|
||||
|
||||
+106
-1
@@ -394,6 +394,64 @@ figcaption a {
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.library-search {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
gap: 10px;
|
||||
align-items: end;
|
||||
margin: 12px 0 18px;
|
||||
}
|
||||
|
||||
.library-search label {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.library-search input {
|
||||
width: 100%;
|
||||
min-height: 48px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--panel-2), transparent 6%);
|
||||
color: var(--text);
|
||||
padding: 12px 14px;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.library-search input:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent), transparent 76%);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.library-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.library-stats article {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: color-mix(in srgb, var(--panel-2), transparent 6%);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.library-stats strong {
|
||||
display: block;
|
||||
color: var(--accent);
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.library-stats span {
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.site-split,
|
||||
.detail-hero,
|
||||
.signup-layout {
|
||||
@@ -555,6 +613,47 @@ textarea:focus {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.admin-source-panel {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: color-mix(in srgb, var(--panel-2), transparent 5%);
|
||||
padding: 14px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.admin-source-form {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 190px auto;
|
||||
gap: 10px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.source-refresh-result {
|
||||
color: var(--muted);
|
||||
font-size: 0.88rem;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.source-refresh-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.source-refresh-list article {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--panel), transparent 10%);
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.source-refresh-list span,
|
||||
.source-refresh-list small {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.admin-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.2fr) minmax(360px, 0.8fr);
|
||||
@@ -718,7 +817,8 @@ textarea:focus {
|
||||
.site-split,
|
||||
.detail-hero,
|
||||
.signup-layout,
|
||||
.admin-layout {
|
||||
.admin-layout,
|
||||
.admin-source-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -753,11 +853,16 @@ textarea:focus {
|
||||
.feature-grid,
|
||||
.detail-grid,
|
||||
.admin-stats,
|
||||
.library-stats,
|
||||
.signup-row,
|
||||
.form-grid.two {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.library-search {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.admin-access-row,
|
||||
.admin-user-main,
|
||||
.panel-heading {
|
||||
|
||||
+35
-8
@@ -6,19 +6,40 @@ $pdo = app_db();
|
||||
|
||||
$pageTitle = 'Medical Terminology Database | Neon Medical Tracker';
|
||||
$activePage = 'terminology';
|
||||
$terms = db_all($pdo, 'SELECT * FROM medical_terms ORDER BY category, term');
|
||||
$conditions = db_all($pdo, 'SELECT * FROM medical_conditions ORDER BY category, name');
|
||||
$medicines = db_all($pdo, 'SELECT * FROM medication_catalog ORDER BY generic_name');
|
||||
$studies = db_all($pdo, 'SELECT * FROM research_studies ORDER BY category, related_medicine, title');
|
||||
$searchQuery = trim((string) ($_GET['q'] ?? ''));
|
||||
$library = library_search_payload(['q' => $searchQuery, 'limit' => 500]);
|
||||
$terms = $library['medicalTerms'];
|
||||
$conditions = $library['medicalConditions'];
|
||||
$medicines = $library['medicationCatalog'];
|
||||
$studies = $library['researchStudies'];
|
||||
$totalMedicines = (int) db_value($pdo, 'SELECT COUNT(*) FROM medication_catalog');
|
||||
|
||||
require __DIR__ . '/partials/site-header.php';
|
||||
?>
|
||||
<main>
|
||||
<section class="admin-public-shell">
|
||||
<div class="site-section-heading">
|
||||
<p class="site-eyebrow">Medical terminology database</p>
|
||||
<h1>Terms, conditions, medicines, and NIH/NLM study links.</h1>
|
||||
<p>Entries are concise app-authored summaries with links to NIH/NLM systems such as MedlinePlus, RxNorm, PubMed, and ClinicalTrials.gov. This is a tracking/reference aid, not medical advice.</p>
|
||||
<p class="site-eyebrow">Searchable medical library</p>
|
||||
<h1>Search terms, conditions, medicines, chemical names, and brands.</h1>
|
||||
<p>Entries are concise app-authored summaries with links to NIH/NLM systems such as MedlinePlus, RxNorm, DailyMed, PubChem, PubMed, and ClinicalTrials.gov. This is a tracking/reference aid, not medical advice.</p>
|
||||
</div>
|
||||
|
||||
<form class="library-search" method="get" action="/medical.php">
|
||||
<label>
|
||||
<span>Search medical library</span>
|
||||
<input name="q" value="<?= htmlspecialchars($searchQuery, ENT_QUOTES) ?>" placeholder="Try Lipitor, atorvastatin, blood pressure, CBC, metformin">
|
||||
</label>
|
||||
<button class="site-primary" type="submit">Search</button>
|
||||
<?php if ($searchQuery !== ''): ?>
|
||||
<a class="site-secondary" href="/medical.php">Clear</a>
|
||||
<?php endif; ?>
|
||||
</form>
|
||||
|
||||
<div class="library-stats" aria-label="Search result counts">
|
||||
<article><strong><?= count($medicines) ?></strong><span><?= $searchQuery === '' ? 'medicines shown of ' . number_format($totalMedicines) : 'medicine results' ?></span></article>
|
||||
<article><strong><?= count($terms) ?></strong><span>terms</span></article>
|
||||
<article><strong><?= count($conditions) ?></strong><span>conditions</span></article>
|
||||
<article><strong><?= count($studies) ?></strong><span>study links</span></article>
|
||||
</div>
|
||||
|
||||
<div class="detail-grid">
|
||||
@@ -57,17 +78,23 @@ require __DIR__ . '/partials/site-header.php';
|
||||
<section class="admin-public-shell">
|
||||
<div class="site-section-heading">
|
||||
<p class="site-eyebrow">Medication catalog</p>
|
||||
<h2>Medicines with RxNorm, PubMed, and ClinicalTrials.gov links</h2>
|
||||
<h2>Medicines with chemical, brand, RxNorm, DailyMed, and PubChem links</h2>
|
||||
</div>
|
||||
<div class="detail-grid">
|
||||
<?php foreach ($medicines as $medicine): ?>
|
||||
<article>
|
||||
<h2><?= htmlspecialchars(ucfirst($medicine['generic_name']), ENT_QUOTES) ?></h2>
|
||||
<p><strong><?= htmlspecialchars($medicine['class_name'], ENT_QUOTES) ?></strong></p>
|
||||
<p><strong>Active ingredient:</strong> <?= htmlspecialchars($medicine['active_ingredient'] ?: $medicine['generic_name'], ENT_QUOTES) ?></p>
|
||||
<p><strong>Chemical name:</strong> <?= htmlspecialchars($medicine['chemical_name'] ?: $medicine['active_ingredient'] ?: $medicine['generic_name'], ENT_QUOTES) ?></p>
|
||||
<p><strong>Common brands:</strong> <?= htmlspecialchars($medicine['brand_names'] ?: 'Brand names vary by market', ENT_QUOTES) ?></p>
|
||||
<p><strong>Generic/alternate names:</strong> <?= htmlspecialchars($medicine['generic_brands'] ?: $medicine['generic_name'], ENT_QUOTES) ?></p>
|
||||
<p><?= htmlspecialchars($medicine['common_use'], ENT_QUOTES) ?></p>
|
||||
<p><?= htmlspecialchars($medicine['tracking_note'], ENT_QUOTES) ?></p>
|
||||
<div class="source-links">
|
||||
<a href="<?= htmlspecialchars($medicine['rxnorm_url'], ENT_QUOTES) ?>">RxNorm</a>
|
||||
<a href="<?= htmlspecialchars($medicine['pubchem_url'], ENT_QUOTES) ?>">PubChem</a>
|
||||
<a href="<?= htmlspecialchars($medicine['dailymed_url'], ENT_QUOTES) ?>">DailyMed</a>
|
||||
<a href="<?= htmlspecialchars($medicine['medlineplus_url'], ENT_QUOTES) ?>">MedlinePlus</a>
|
||||
<a href="<?= htmlspecialchars($medicine['pubmed_url'], ENT_QUOTES) ?>">PubMed</a>
|
||||
<a href="<?= htmlspecialchars($medicine['clinicaltrials_url'], ENT_QUOTES) ?>">Studies</a>
|
||||
|
||||
+28
-3
@@ -6,7 +6,10 @@ $pdo = app_db();
|
||||
|
||||
$pageTitle = 'Medicine Catalog | Neon Medical Tracker';
|
||||
$activePage = 'medicines';
|
||||
$medicines = db_all($pdo, 'SELECT * FROM medication_catalog ORDER BY generic_name');
|
||||
$searchQuery = trim((string) ($_GET['q'] ?? ''));
|
||||
$library = library_search_payload(['q' => $searchQuery, 'limit' => 500]);
|
||||
$medicines = $library['medicationCatalog'];
|
||||
$totalMedicines = (int) db_value($pdo, 'SELECT COUNT(*) FROM medication_catalog');
|
||||
$photo = 'https://upload.wikimedia.org/wikipedia/commons/thumb/4/4f/A_large_mixed_salad.jpg/1024px-A_large_mixed_salad.jpg';
|
||||
require __DIR__ . '/partials/site-header.php';
|
||||
?>
|
||||
@@ -21,21 +24,43 @@ require __DIR__ . '/partials/site-header.php';
|
||||
</figure>
|
||||
<div>
|
||||
<p class="site-eyebrow">Medicine catalog</p>
|
||||
<h1>Track medicines with source links beside every catalog entry.</h1>
|
||||
<p>The starter catalog includes common medicines and links each one to RxNorm, MedlinePlus, PubMed, and ClinicalTrials.gov searches.</p>
|
||||
<h1>Search medicines by generic, chemical, brand, and alternate names.</h1>
|
||||
<p>The catalog includes common medicines and links each one to RxNorm, DailyMed, PubChem, MedlinePlus, PubMed, and ClinicalTrials.gov searches.</p>
|
||||
<a class="site-primary" href="/tracker.php">Open medical tracker</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="admin-public-shell">
|
||||
<form class="library-search" method="get" action="/nutrition.php">
|
||||
<label>
|
||||
<span>Search medicine catalog</span>
|
||||
<input name="q" value="<?= htmlspecialchars($searchQuery, ENT_QUOTES) ?>" placeholder="Try Zoloft, sertraline, insulin, acetaminophen, blood pressure">
|
||||
</label>
|
||||
<button class="site-primary" type="submit">Search</button>
|
||||
<?php if ($searchQuery !== ''): ?>
|
||||
<a class="site-secondary" href="/nutrition.php">Clear</a>
|
||||
<?php endif; ?>
|
||||
</form>
|
||||
<div class="library-stats" aria-label="Medicine result count">
|
||||
<article><strong><?= count($medicines) ?></strong><span><?= $searchQuery === '' ? 'shown of ' . number_format($totalMedicines) . ' medicines' : 'medicine results' ?></span></article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="detail-grid">
|
||||
<?php foreach ($medicines as $medicine): ?>
|
||||
<article>
|
||||
<h2><?= htmlspecialchars(ucfirst($medicine['generic_name']), ENT_QUOTES) ?></h2>
|
||||
<p><strong><?= htmlspecialchars($medicine['class_name'], ENT_QUOTES) ?></strong></p>
|
||||
<p><strong>Active ingredient:</strong> <?= htmlspecialchars($medicine['active_ingredient'] ?: $medicine['generic_name'], ENT_QUOTES) ?></p>
|
||||
<p><strong>Chemical name:</strong> <?= htmlspecialchars($medicine['chemical_name'] ?: $medicine['active_ingredient'] ?: $medicine['generic_name'], ENT_QUOTES) ?></p>
|
||||
<p><strong>Common brands:</strong> <?= htmlspecialchars($medicine['brand_names'] ?: 'Brand names vary by market', ENT_QUOTES) ?></p>
|
||||
<p><strong>Generic/alternate names:</strong> <?= htmlspecialchars($medicine['generic_brands'] ?: $medicine['generic_name'], ENT_QUOTES) ?></p>
|
||||
<p><?= htmlspecialchars($medicine['common_use'], ENT_QUOTES) ?></p>
|
||||
<p><?= htmlspecialchars($medicine['tracking_note'], ENT_QUOTES) ?></p>
|
||||
<div class="source-links">
|
||||
<a href="<?= htmlspecialchars($medicine['rxnorm_url'], ENT_QUOTES) ?>">RxNorm</a>
|
||||
<a href="<?= htmlspecialchars($medicine['pubchem_url'], ENT_QUOTES) ?>">PubChem</a>
|
||||
<a href="<?= htmlspecialchars($medicine['dailymed_url'], ENT_QUOTES) ?>">DailyMed</a>
|
||||
<a href="<?= htmlspecialchars($medicine['medlineplus_url'], ENT_QUOTES) ?>">MedlinePlus</a>
|
||||
<a href="<?= htmlspecialchars($medicine['pubmed_url'], ENT_QUOTES) ?>">PubMed</a>
|
||||
<a href="<?= htmlspecialchars($medicine['clinicaltrials_url'], ENT_QUOTES) ?>">Studies</a>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/../app/bootstrap.php';
|
||||
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
fwrite(STDERR, "This script is intended for CLI use.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$limit = isset($argv[1]) ? (int) $argv[1] : 1000;
|
||||
$source = $argv[2] ?? 'all';
|
||||
|
||||
$result = refresh_medication_sources([
|
||||
'limit' => $limit,
|
||||
'source' => $source,
|
||||
]);
|
||||
|
||||
echo "Medication catalog refresh complete\n";
|
||||
echo "Catalog count: {$result['catalog_count']}\n";
|
||||
echo "Inserted: {$result['inserted']}\n";
|
||||
echo "Updated: {$result['updated']}\n";
|
||||
|
||||
foreach ($result['sources'] as $sourceResult) {
|
||||
echo "- {$sourceResult['source']}: {$sourceResult['status']} ";
|
||||
echo "({$sourceResult['inserted']} inserted, {$sourceResult['updated']} updated) ";
|
||||
echo "{$sourceResult['message']}\n";
|
||||
}
|
||||
Reference in New Issue
Block a user