[object Object]

← back to AbramsOS

feat(health): Household (people/spouse) + Medications with FDA recall check

63d53f9c2cba0084ff640897982da8cc01fd4731 · 2026-07-07 08:04:56 -0700 · Steve

- migration 0008: person, medication, medication_recall
- /household — add spouse/dependents (records + meds attach to them)
- /medications — self-entered meds per person; every write audit-logged with a medical-consent marker (AGENTS.md gate)
- lib/fda-fetcher.js — openFDA drug-enforcement lookup; per-med + "check all" recall scan stores matches, surfaced on the card
- nav links, recall styling

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 63d53f9c2cba0084ff640897982da8cc01fd4731
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue Jul 7 08:04:56 2026 -0700

    feat(health): Household (people/spouse) + Medications with FDA recall check
    
    - migration 0008: person, medication, medication_recall
    - /household — add spouse/dependents (records + meds attach to them)
    - /medications — self-entered meds per person; every write audit-logged with a medical-consent marker (AGENTS.md gate)
    - lib/fda-fetcher.js — openFDA drug-enforcement lookup; per-med + "check all" recall scan stores matches, surfaced on the card
    - nav links, recall styling
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 db/migrations/0008_household_medications.sql |  65 ++++++++++++
 lib/fda-fetcher.js                           |  45 +++++++++
 lib/ids.js                                   |   3 +
 public/css/app.css                           |   5 +
 routes/medications.js                        | 142 +++++++++++++++++++++++++++
 routes/people.js                             |  75 ++++++++++++++
 server.js                                    |   4 +
 views/household.ejs                          |  85 ++++++++++++++++
 views/medications.ejs                        | 126 ++++++++++++++++++++++++
 views/partials/header.ejs                    |   2 +
 10 files changed, 552 insertions(+)

diff --git a/db/migrations/0008_household_medications.sql b/db/migrations/0008_household_medications.sql
new file mode 100644
index 0000000..9493a45
--- /dev/null
+++ b/db/migrations/0008_household_medications.sql
@@ -0,0 +1,65 @@
+-- 0008_household_medications.sql
+--   person             — household members (self, spouse, dependents…)
+--   medication         — meds a person takes (self-entered; health data — see AGENTS.md gate)
+--   medication_recall  — FDA (openFDA) drug-enforcement matches for a medication
+-- Idempotent. Safe to re-run.
+
+BEGIN;
+
+CREATE TABLE IF NOT EXISTS person (
+  id             text PRIMARY KEY,
+  user_id        text NOT NULL REFERENCES user_account(id) ON DELETE CASCADE,
+  relation       text NOT NULL DEFAULT 'other',   -- self|spouse|partner|child|dependent|parent|other
+  full_name      text NOT NULL,
+  nickname       text,
+  dob            date,
+  phone          text,
+  email          text,
+  notes          text,
+  metadata_jsonb jsonb NOT NULL DEFAULT '{}'::jsonb,
+  created_at     timestamptz NOT NULL DEFAULT now(),
+  updated_at     timestamptz NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS person_user_idx ON person (user_id, relation);
+
+CREATE TABLE IF NOT EXISTS medication (
+  id             text PRIMARY KEY,
+  user_id        text NOT NULL REFERENCES user_account(id) ON DELETE CASCADE,
+  person_id      text REFERENCES person(id) ON DELETE SET NULL,   -- who takes it (null = account owner)
+  name           text NOT NULL,                                   -- brand/name as taken
+  generic_name   text,
+  dosage         text,                                            -- e.g. "20 mg"
+  frequency      text,                                            -- e.g. "once daily", "2x/day"
+  form           text,                                            -- tablet|capsule|liquid|injection|...
+  prescriber     text,
+  pharmacy       text,
+  rx_number      text,
+  start_date     date,
+  is_active      boolean NOT NULL DEFAULT true,
+  notes          text,
+  metadata_jsonb jsonb NOT NULL DEFAULT '{}'::jsonb,
+  created_at     timestamptz NOT NULL DEFAULT now(),
+  updated_at     timestamptz NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS medication_user_idx ON medication (user_id, is_active);
+CREATE INDEX IF NOT EXISTS medication_person_idx ON medication (person_id);
+
+CREATE TABLE IF NOT EXISTS medication_recall (
+  id                     text PRIMARY KEY,
+  user_id                text NOT NULL REFERENCES user_account(id) ON DELETE CASCADE,
+  medication_id          text NOT NULL REFERENCES medication(id) ON DELETE CASCADE,
+  source                 text NOT NULL DEFAULT 'openFDA',
+  recall_number          text,
+  classification         text,                     -- Class I / II / III
+  status                 text,                      -- Ongoing / Completed / Terminated
+  reason                 text,
+  product_description     text,
+  recall_initiation_date date,
+  url                    text,
+  raw_jsonb              jsonb,
+  matched_at             timestamptz NOT NULL DEFAULT now(),
+  UNIQUE (medication_id, recall_number)
+);
+CREATE INDEX IF NOT EXISTS medication_recall_med_idx ON medication_recall (medication_id);
+
+COMMIT;
diff --git a/lib/fda-fetcher.js b/lib/fda-fetcher.js
new file mode 100644
index 0000000..1889e51
--- /dev/null
+++ b/lib/fda-fetcher.js
@@ -0,0 +1,45 @@
+// openFDA drug-enforcement (recall) lookup. Free public API — a drug NAME is not
+// identifying, so no PHI leaves the box beyond the med name itself (same posture as
+// checking whether a product you own was recalled). Optional OPENFDA_API_KEY raises
+// the rate limit but is not required.
+
+const BASE = 'https://api.fda.gov/drug/enforcement.json';
+
+function isoFromFdaDate(d) {
+  // openFDA dates are YYYYMMDD strings
+  if (!d || d.length !== 8) return null;
+  return `${d.slice(0, 4)}-${d.slice(4, 6)}-${d.slice(6, 8)}`;
+}
+
+function normalize(r) {
+  return {
+    recall_number: r.recall_number || null,
+    classification: r.classification || null,
+    status: r.status || null,
+    reason: r.reason_for_recall || null,
+    product_description: r.product_description || null,
+    recall_initiation_date: isoFromFdaDate(r.recall_initiation_date),
+    url: r.recall_number
+      ? `https://www.accessdata.fda.gov/scripts/ires/index.cfm?Product=${encodeURIComponent(r.recall_number)}`
+      : 'https://www.fda.gov/drugs/drug-safety-and-availability/drug-recalls',
+    raw: r,
+  };
+}
+
+// Look up recalls for a single medication name. Matches brand OR generic OR
+// product description. Returns [] when the FDA has no matching recall (404).
+async function fetchDrugRecalls(name, { limit = 10 } = {}) {
+  if (!name) return [];
+  const q = String(name).trim().replace(/"/g, '');
+  const search = `openfda.brand_name:"${q}"+openfda.generic_name:"${q}"+product_description:"${q}"`;
+  const key = process.env.OPENFDA_API_KEY ? `&api_key=${process.env.OPENFDA_API_KEY}` : '';
+  const url = `${BASE}?search=${encodeURIComponent(search).replace(/%2B/g, '+')}&limit=${limit}${key}`;
+
+  const res = await fetch(url, { headers: { 'User-Agent': 'AbramsOS/1.0 (personal household admin)' } });
+  if (res.status === 404) return [];               // openFDA returns 404 for "no matches"
+  if (!res.ok) throw new Error(`openFDA ${res.status}`);
+  const json = await res.json();
+  return (json.results || []).map(normalize);
+}
+
+module.exports = { fetchDrugRecalls, normalize };
diff --git a/lib/ids.js b/lib/ids.js
index 721bae0..4f414f3 100644
--- a/lib/ids.js
+++ b/lib/ids.js
@@ -12,6 +12,9 @@ const PREFIX = {
   reorder: 'reord',
   reminder: 'rem',
   warranty: 'wty',
+  person: 'prsn',
+  medication: 'med',
+  medrecall: 'mrcl',
 };
 
 function id(kind) {
diff --git a/public/css/app.css b/public/css/app.css
index 892949f..862c026 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -366,3 +366,8 @@ form input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0
 .deadline .dl-body { flex: 1; }
 .deadline .dl-body h3 { margin: 0 0 4px; font-size: 15px; }
 .deadline .dl-body p { margin: 0 0 6px; font-size: 13px; }
+
+.purchase.inactive { opacity: 0.6; }
+.recalls { margin: 10px 0 4px; display: flex; flex-direction: column; gap: 8px; }
+.recall-item { border-left: 3px solid var(--danger); padding: 6px 10px; background: rgba(248,113,113,0.06); border-radius: 6px; font-size: 12px; }
+.recall-item strong { color: var(--danger); }
diff --git a/routes/medications.js b/routes/medications.js
new file mode 100644
index 0000000..884660c
--- /dev/null
+++ b/routes/medications.js
@@ -0,0 +1,142 @@
+// Medications — self-entered meds per household member, with on-demand FDA recall check.
+// Health data: AGENTS.md gates medical features. These rows are user-entered (allowed),
+// and every write is audit-logged with a medical-consent marker.
+const express = require('express');
+const db = require('../lib/db');
+const audit = require('../lib/audit');
+const { id } = require('../lib/ids');
+const fda = require('../lib/fda-fetcher');
+
+const router = express.Router();
+const DEV_USER_ID = 'user_steve';
+
+async function listMeds(userId) {
+  const r = await db.query(
+    `SELECT m.*, p.full_name AS person_name, p.relation AS person_relation,
+            (SELECT count(*)::int FROM medication_recall mr WHERE mr.medication_id = m.id) AS recall_count,
+            (SELECT max(matched_at) FROM medication_recall mr WHERE mr.medication_id = m.id) AS last_checked
+       FROM medication m LEFT JOIN person p ON p.id = m.person_id
+      WHERE m.user_id = $1
+      ORDER BY m.is_active DESC, m.name ASC`,
+    [userId]
+  );
+  return r.rows;
+}
+
+async function recallsFor(userId, medId) {
+  const r = await db.query(
+    `SELECT * FROM medication_recall WHERE user_id = $1 AND medication_id = $2 ORDER BY recall_initiation_date DESC NULLS LAST`,
+    [userId, medId]
+  );
+  return r.rows;
+}
+
+router.get('/medications', async (_req, res) => {
+  const [meds, people] = await Promise.all([
+    listMeds(DEV_USER_ID),
+    db.query(`SELECT id, full_name, relation FROM person WHERE user_id = $1 ORDER BY (relation<>'self'), full_name`, [DEV_USER_ID]),
+  ]);
+  // attach stored recalls to each med for render
+  for (const m of meds) m.recalls = m.recall_count ? await recallsFor(DEV_USER_ID, m.id) : [];
+  res.render('medications', { meds, people: people.rows });
+});
+
+router.get('/api/medications', async (_req, res) => {
+  res.json(await listMeds(DEV_USER_ID));
+});
+
+router.post('/api/medications', async (req, res) => {
+  try {
+    const b = req.body || {};
+    if (!b.name) return res.status(400).json({ error: 'name required' });
+    const mId = id('medication');
+    await db.query(
+      `INSERT INTO medication (id, user_id, person_id, name, generic_name, dosage, frequency, form, prescriber, pharmacy, rx_number, start_date, is_active, notes)
+       VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`,
+      [mId, DEV_USER_ID, b.person_id || null, b.name, b.generic_name || null, b.dosage || null,
+       b.frequency || null, b.form || null, b.prescriber || null, b.pharmacy || null, b.rx_number || null,
+       b.start_date || null, b.is_active === false || b.is_active === 'false' ? false : true, b.notes || null]
+    );
+    await audit.log({ actorType: 'user', actorId: DEV_USER_ID, objectType: 'medication', objectId: mId, eventType: 'medication_created', metadata: { medical: true, consent: 'user-entered', name: b.name } });
+    const r = await db.query(`SELECT * FROM medication WHERE id = $1`, [mId]);
+    res.status(201).json(r.rows[0]);
+  } catch (err) {
+    res.status(500).json({ error: err.message });
+  }
+});
+
+router.post('/api/medications/:id', async (req, res) => {
+  try {
+    const b = req.body || {};
+    const fields = ['person_id', 'name', 'generic_name', 'dosage', 'frequency', 'form', 'prescriber', 'pharmacy', 'rx_number', 'start_date', 'is_active', 'notes'];
+    const sets = [], vals = [];
+    let i = 1;
+    for (const f of fields) {
+      if (b[f] === undefined) continue;
+      sets.push(`${f} = $${i++}`);
+      vals.push(f === 'is_active' ? !(b[f] === false || b[f] === 'false') : (b[f] === '' ? null : b[f]));
+    }
+    if (!sets.length) return res.status(400).json({ error: 'no fields to update' });
+    sets.push(`updated_at = now()`);
+    vals.push(req.params.id, DEV_USER_ID);
+    const r = await db.query(`UPDATE medication SET ${sets.join(', ')} WHERE id = $${i++} AND user_id = $${i} RETURNING *`, vals);
+    if (!r.rows.length) return res.status(404).json({ error: 'not found' });
+    await audit.log({ actorType: 'user', actorId: DEV_USER_ID, objectType: 'medication', objectId: req.params.id, eventType: 'medication_updated', metadata: { medical: true } });
+    res.json(r.rows[0]);
+  } catch (err) {
+    res.status(500).json({ error: err.message });
+  }
+});
+
+router.post('/api/medications/:id/delete', async (req, res) => {
+  await db.query(`DELETE FROM medication WHERE id = $1 AND user_id = $2`, [req.params.id, DEV_USER_ID]);
+  await audit.log({ actorType: 'user', actorId: DEV_USER_ID, objectType: 'medication', objectId: req.params.id, eventType: 'medication_deleted', metadata: { medical: true } });
+  res.json({ ok: true });
+});
+
+// Check FDA recalls for one medication → upsert matches into medication_recall.
+async function checkOne(userId, med) {
+  const hits = await fda.fetchDrugRecalls(med.generic_name || med.name);
+  // also try brand name if generic differs
+  if (med.generic_name && med.name && med.generic_name !== med.name) {
+    const more = await fda.fetchDrugRecalls(med.name);
+    for (const h of more) if (!hits.find((x) => x.recall_number === h.recall_number)) hits.push(h);
+  }
+  let stored = 0;
+  for (const h of hits) {
+    const r = await db.query(
+      `INSERT INTO medication_recall (id, user_id, medication_id, source, recall_number, classification, status, reason, product_description, recall_initiation_date, url, raw_jsonb)
+       VALUES ($1,$2,$3,'openFDA',$4,$5,$6,$7,$8,$9,$10,$11)
+       ON CONFLICT (medication_id, recall_number) DO UPDATE SET status = EXCLUDED.status, reason = EXCLUDED.reason, matched_at = now()
+       RETURNING (xmax = 0) AS inserted`,
+      [id('medrecall'), userId, med.id, h.recall_number, h.classification, h.status, h.reason, h.product_description, h.recall_initiation_date, h.url, JSON.stringify(h.raw)]
+    );
+    if (r.rows[0].inserted) stored += 1;
+  }
+  await audit.log({ actorType: 'user', actorId: userId, objectType: 'medication', objectId: med.id, eventType: 'medication_recall_checked', metadata: { medical: true, source: 'openFDA', hits: hits.length } });
+  return { medication_id: med.id, name: med.name, hits: hits.length, new: stored };
+}
+
+router.post('/api/medications/:id/check-recalls', async (req, res) => {
+  try {
+    const m = await db.query(`SELECT * FROM medication WHERE id = $1 AND user_id = $2`, [req.params.id, DEV_USER_ID]);
+    if (!m.rows.length) return res.status(404).json({ error: 'not found' });
+    const summary = await checkOne(DEV_USER_ID, m.rows[0]);
+    res.json({ ...summary, recalls: await recallsFor(DEV_USER_ID, req.params.id) });
+  } catch (err) {
+    res.status(500).json({ error: `recall check failed: ${err.message}` });
+  }
+});
+
+router.post('/api/medications/check-all-recalls', async (_req, res) => {
+  try {
+    const meds = await db.query(`SELECT * FROM medication WHERE user_id = $1 AND is_active`, [DEV_USER_ID]);
+    const results = [];
+    for (const med of meds.rows) results.push(await checkOne(DEV_USER_ID, med));
+    res.json({ checked: results.length, results });
+  } catch (err) {
+    res.status(500).json({ error: `recall check failed: ${err.message}` });
+  }
+});
+
+module.exports = router;
diff --git a/routes/people.js b/routes/people.js
new file mode 100644
index 0000000..63b06bd
--- /dev/null
+++ b/routes/people.js
@@ -0,0 +1,75 @@
+// Household — people (self, spouse, dependents) whose records AbramsOS tracks.
+const express = require('express');
+const db = require('../lib/db');
+const audit = require('../lib/audit');
+const { id } = require('../lib/ids');
+
+const router = express.Router();
+const DEV_USER_ID = 'user_steve';
+
+const RELATIONS = ['self', 'spouse', 'partner', 'child', 'dependent', 'parent', 'other'];
+
+async function listPeople(userId) {
+  const r = await db.query(
+    `SELECT p.*,
+            (SELECT count(*)::int FROM medication m WHERE m.person_id = p.id AND m.is_active) AS med_count
+       FROM person p WHERE p.user_id = $1
+      ORDER BY (relation <> 'self'), full_name ASC`,
+    [userId]
+  );
+  return r.rows;
+}
+
+router.get('/household', async (_req, res) => {
+  res.render('household', { people: await listPeople(DEV_USER_ID), relations: RELATIONS });
+});
+
+router.get('/api/people', async (_req, res) => {
+  res.json(await listPeople(DEV_USER_ID));
+});
+
+router.post('/api/people', async (req, res) => {
+  try {
+    const b = req.body || {};
+    if (!b.full_name) return res.status(400).json({ error: 'full_name required' });
+    const pid = id('person');
+    await db.query(
+      `INSERT INTO person (id, user_id, relation, full_name, nickname, dob, phone, email, notes)
+       VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
+      [pid, DEV_USER_ID, RELATIONS.includes(b.relation) ? b.relation : 'other', b.full_name,
+       b.nickname || null, b.dob || null, b.phone || null, b.email || null, b.notes || null]
+    );
+    await audit.log({ actorType: 'user', actorId: DEV_USER_ID, objectType: 'person', objectId: pid, eventType: 'person_created', metadata: { relation: b.relation } });
+    const r = await db.query(`SELECT * FROM person WHERE id = $1`, [pid]);
+    res.status(201).json(r.rows[0]);
+  } catch (err) {
+    res.status(500).json({ error: err.message });
+  }
+});
+
+router.post('/api/people/:id', async (req, res) => {
+  try {
+    const b = req.body || {};
+    const fields = ['relation', 'full_name', 'nickname', 'dob', 'phone', 'email', 'notes'];
+    const sets = [], vals = [];
+    let i = 1;
+    for (const f of fields) { if (b[f] === undefined) continue; sets.push(`${f} = $${i++}`); vals.push(b[f] === '' ? null : b[f]); }
+    if (!sets.length) return res.status(400).json({ error: 'no fields to update' });
+    sets.push(`updated_at = now()`);
+    vals.push(req.params.id, DEV_USER_ID);
+    const r = await db.query(`UPDATE person SET ${sets.join(', ')} WHERE id = $${i++} AND user_id = $${i} RETURNING *`, vals);
+    if (!r.rows.length) return res.status(404).json({ error: 'not found' });
+    await audit.log({ actorType: 'user', actorId: DEV_USER_ID, objectType: 'person', objectId: req.params.id, eventType: 'person_updated' });
+    res.json(r.rows[0]);
+  } catch (err) {
+    res.status(500).json({ error: err.message });
+  }
+});
+
+router.post('/api/people/:id/delete', async (req, res) => {
+  await db.query(`DELETE FROM person WHERE id = $1 AND user_id = $2`, [req.params.id, DEV_USER_ID]);
+  await audit.log({ actorType: 'user', actorId: DEV_USER_ID, objectType: 'person', objectId: req.params.id, eventType: 'person_deleted' });
+  res.json({ ok: true });
+});
+
+module.exports = router;
diff --git a/server.js b/server.js
index 42277d6..fcc24cf 100644
--- a/server.js
+++ b/server.js
@@ -25,6 +25,8 @@ const uploadRouter = require('./routes/upload');
 const billsRouter = require('./routes/bills');
 const reordersRouter = require('./routes/reorders');
 const warrantiesRouter = require('./routes/warranties');
+const peopleRouter = require('./routes/people');
+const medicationsRouter = require('./routes/medications');
 
 const app = express();
 const PORT = parseInt(process.env.PORT || '9931', 10);
@@ -85,6 +87,8 @@ app.use(uploadRouter);              // /api/upload/csv
 app.use(billsRouter);               // /bills, /api/bills*
 app.use(reordersRouter);            // /reorders, /api/reorders*
 app.use(warrantiesRouter);          // /warranties, /api/warranties*
+app.use(peopleRouter);              // /household, /api/people*
+app.use(medicationsRouter);         // /medications, /api/medications*
 
 // Step-up-required routes (must re-verify TOTP within 60s)
 app.use('/import', requireStepUp, importRouter);
diff --git a/views/household.ejs b/views/household.ejs
new file mode 100644
index 0000000..63ffa07
--- /dev/null
+++ b/views/household.ejs
@@ -0,0 +1,85 @@
+<%- include('partials/header', { title: 'Household' }) %>
+
+<section class="page-head">
+  <div>
+    <h1>Household</h1>
+    <p class="subtle"><%= people.length %> <%= people.length === 1 ? 'person' : 'people' %> · add your spouse / dependents; their <a href="/medications">medications</a> and records attach to them.</p>
+  </div>
+  <div class="grid-controls">
+    <label>Sort
+      <select data-sort-for="person">
+        <option value="name-asc">Name A→Z</option>
+        <option value="relation-asc">Relation</option>
+        <option value="created-desc">Newest added</option>
+      </select>
+    </label>
+    <label>Density<input data-density-for="person" type="range" min="240" max="460" step="10" value="300"></label>
+    <button type="button" class="btn" id="toggleAdd">+ Add person</button>
+  </div>
+</section>
+
+<form id="addForm" class="add-form glass" hidden>
+  <div class="row">
+    <label>Full name*<input name="full_name" required placeholder="Jane Abrams"></label>
+    <label>Relation
+      <select name="relation"><% relations.forEach(r => { %><option value="<%= r %>" <%= r==='spouse'?'selected':'' %>><%= r %></option><% }) %></select>
+    </label>
+    <label>Nickname<input name="nickname" placeholder="optional"></label>
+    <label>DOB<input name="dob" type="date"></label>
+  </div>
+  <div class="row">
+    <label>Phone<input name="phone" placeholder="optional"></label>
+    <label>Email<input name="email" type="email" placeholder="optional"></label>
+    <label class="grow">Notes<input name="notes" placeholder="optional"></label>
+  </div>
+  <div class="row"><button type="submit" class="btn primary">Save person</button></div>
+</form>
+
+<% if (!people.length) { %>
+  <section class="empty glass"><p>No one added yet. Click <strong>+ Add person</strong> to add your spouse or a dependent.</p></section>
+<% } %>
+
+<section data-grid="person" class="purchase-grid" style="grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));">
+  <% people.forEach(p => { %>
+    <article class="purchase glass"
+             data-name="<%= (p.full_name||'').toLowerCase() %>"
+             data-relation="<%= p.relation %>"
+             data-created="<%= new Date(p.created_at).toISOString() %>">
+      <header>
+        <h3><%= p.full_name %><% if (p.nickname) { %> <span class="subtle">(<%= p.nickname %>)</span><% } %></h3>
+        <span class="chip cat"><%= p.relation %></span>
+      </header>
+      <dl class="meta">
+        <% if (p.dob) { %><dt>DOB</dt><dd><%= p.dob %></dd><% } %>
+        <% if (p.phone) { %><dt>Phone</dt><dd><%= p.phone %></dd><% } %>
+        <% if (p.email) { %><dt>Email</dt><dd><%= p.email %></dd><% } %>
+        <% if (p.notes) { %><dt>Notes</dt><dd><%= p.notes %></dd><% } %>
+        <dt>Meds</dt><dd><%= p.med_count %> active <% if (p.med_count) { %><a href="/medications">view →</a><% } %></dd>
+      </dl>
+      <div class="when" title="<%= new Date(p.created_at).toISOString() %>">🕓 added <%= new Date(p.created_at).toLocaleString(undefined, { year:'numeric', month:'short', day:'numeric', hour:'numeric', minute:'2-digit' }) %></div>
+      <div class="card-actions">
+        <button class="btn small danger" data-action="delete" data-id="<%= p.id %>">Delete</button>
+      </div>
+    </article>
+  <% }) %>
+</section>
+
+<script>
+  const $ = (s, r = document) => r.querySelector(s);
+  $('#toggleAdd')?.addEventListener('click', () => { const f = $('#addForm'); f.hidden = !f.hidden; });
+  $('#addForm')?.addEventListener('submit', async (e) => {
+    e.preventDefault();
+    const body = Object.fromEntries(new FormData(e.target).entries());
+    const res = await fetch('/api/people', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
+    if (res.ok) location.reload(); else alert('Save failed: ' + (await res.text()));
+  });
+  document.querySelectorAll('[data-action="delete"]').forEach((btn) => {
+    btn.addEventListener('click', async () => {
+      if (!confirm('Delete this person? (their medications stay but unlink)')) return;
+      const res = await fetch(`/api/people/${btn.dataset.id}/delete`, { method: 'POST' });
+      if (res.ok) location.reload(); else alert('Delete failed');
+    });
+  });
+</script>
+
+<%- include('partials/footer', { gridScript: true }) %>
diff --git a/views/medications.ejs b/views/medications.ejs
new file mode 100644
index 0000000..4236e3c
--- /dev/null
+++ b/views/medications.ejs
@@ -0,0 +1,126 @@
+<%- include('partials/header', { title: 'Medications' }) %>
+
+<section class="page-head">
+  <div>
+    <h1>Medications</h1>
+    <p class="subtle"><%= meds.length %> tracked · recall checks query the <strong>FDA (openFDA)</strong> drug-enforcement database by drug name.</p>
+  </div>
+  <div class="grid-controls">
+    <label>Sort
+      <select data-sort-for="med">
+        <option value="name-asc">Name A→Z</option>
+        <option value="recall-desc">Recalls first</option>
+        <option value="created-desc">Newest added</option>
+      </select>
+    </label>
+    <label>Density<input data-density-for="med" type="range" min="240" max="480" step="10" value="340"></label>
+    <button type="button" class="btn" id="checkAll">🔎 Check all recalls</button>
+    <button type="button" class="btn" id="toggleAdd">+ Add medication</button>
+  </div>
+</section>
+
+<section class="glass" style="padding:10px 14px;margin:0 0 14px;font-size:12px;color:var(--text-dim)">
+  🔒 <strong>Health data.</strong> Medications are stored locally in your own AbramsOS DB (single-user), not shared. Recall checks send only the drug name to the FDA's public API — no identity. Entering a med records your consent (audit-logged).
+</section>
+
+<form id="addForm" class="add-form glass" hidden>
+  <div class="row">
+    <label>Medication name*<input name="name" required placeholder="Lipitor"></label>
+    <label>Generic<input name="generic_name" placeholder="atorvastatin"></label>
+    <label>For
+      <select name="person_id">
+        <option value="">Me (account owner)</option>
+        <% people.forEach(p => { %><option value="<%= p.id %>"><%= p.full_name %> (<%= p.relation %>)</option><% }) %>
+      </select>
+    </label>
+  </div>
+  <div class="row">
+    <label>Dosage<input name="dosage" placeholder="20 mg"></label>
+    <label>Frequency<input name="frequency" placeholder="once daily"></label>
+    <label>Form<input name="form" placeholder="tablet"></label>
+    <label>Start date<input name="start_date" type="date"></label>
+  </div>
+  <div class="row">
+    <label>Prescriber<input name="prescriber" placeholder="Dr. …"></label>
+    <label>Pharmacy<input name="pharmacy" placeholder="optional"></label>
+    <label>Rx #<input name="rx_number" placeholder="optional"></label>
+    <label class="grow">Notes<input name="notes" placeholder="optional"></label>
+  </div>
+  <div class="row"><button type="submit" class="btn primary">Save medication</button></div>
+</form>
+
+<% if (!meds.length) { %>
+  <section class="empty glass"><p>No medications yet. Click <strong>+ Add medication</strong> — then run a recall check.</p></section>
+<% } %>
+
+<section data-grid="med" class="purchase-grid" style="grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));">
+  <% meds.forEach(m => { %>
+    <article class="purchase glass<%= m.recall_count ? ' overdue' : '' %><%= m.is_active ? '' : ' inactive' %>"
+             data-name="<%= (m.name||'').toLowerCase() %>"
+             data-recall="<%= m.recall_count || 0 %>"
+             data-created="<%= new Date(m.created_at).toISOString() %>">
+      <header>
+        <h3><%= m.name %><% if (m.generic_name) { %> <span class="subtle">(<%= m.generic_name %>)</span><% } %></h3>
+        <% if (m.recall_count) { %><span class="badge red"><%= m.recall_count %> recall<%= m.recall_count>1?'s':'' %></span><% } else if (m.last_checked) { %><span class="badge">✓ clear</span><% } %>
+      </header>
+      <div class="amount" style="font-size:14px;font-weight:500">
+        <%= m.dosage || '' %><% if (m.frequency) { %> · <%= m.frequency %><% } %>
+        <% if (!m.is_active) { %><span class="subtle"> · inactive</span><% } %>
+      </div>
+      <dl class="meta">
+        <dt>For</dt><dd><%= m.person_name ? m.person_name + ' (' + m.person_relation + ')' : 'Me' %></dd>
+        <% if (m.form) { %><dt>Form</dt><dd><%= m.form %></dd><% } %>
+        <% if (m.prescriber) { %><dt>Prescriber</dt><dd><%= m.prescriber %></dd><% } %>
+        <% if (m.pharmacy) { %><dt>Pharmacy</dt><dd><%= m.pharmacy %></dd><% } %>
+        <% if (m.last_checked) { %><dt>Checked</dt><dd><%= new Date(m.last_checked).toLocaleDateString() %></dd><% } %>
+      </dl>
+      <% if (m.recalls && m.recalls.length) { %>
+        <div class="recalls">
+          <% m.recalls.forEach(rc => { %>
+            <div class="recall-item">
+              <strong><%= rc.classification || 'Recall' %></strong> <span class="subtle"><%= rc.status || '' %><% if (rc.recall_initiation_date) { %> · <%= rc.recall_initiation_date %><% } %></span>
+              <div class="subtle"><%= (rc.reason || '').slice(0,180) %></div>
+              <% if (rc.url) { %><a href="<%= rc.url %>" target="_blank" rel="noopener noreferrer">FDA notice →</a><% } %>
+            </div>
+          <% }) %>
+        </div>
+      <% } %>
+      <div class="when" title="<%= new Date(m.created_at).toISOString() %>">🕓 added <%= new Date(m.created_at).toLocaleString(undefined, { year:'numeric', month:'short', day:'numeric', hour:'numeric', minute:'2-digit' }) %></div>
+      <div class="card-actions">
+        <button class="btn small" data-action="check" data-id="<%= m.id %>">Check recalls</button>
+        <button class="btn small danger" data-action="delete" data-id="<%= m.id %>">Delete</button>
+      </div>
+    </article>
+  <% }) %>
+</section>
+
+<script>
+  const $ = (s, r = document) => r.querySelector(s);
+  $('#toggleAdd')?.addEventListener('click', () => { const f = $('#addForm'); f.hidden = !f.hidden; });
+  $('#addForm')?.addEventListener('submit', async (e) => {
+    e.preventDefault();
+    const body = Object.fromEntries(new FormData(e.target).entries());
+    const res = await fetch('/api/medications', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
+    if (res.ok) location.reload(); else alert('Save failed: ' + (await res.text()));
+  });
+  $('#checkAll')?.addEventListener('click', async () => {
+    $('#checkAll').textContent = 'Checking FDA…'; $('#checkAll').disabled = true;
+    const res = await fetch('/api/medications/check-all-recalls', { method: 'POST' });
+    const j = await res.json().catch(() => ({}));
+    if (res.ok) { const hits = (j.results || []).reduce((s, r) => s + r.hits, 0); alert(`Checked ${j.checked} med(s) against the FDA. ${hits} recall record(s) matched.`); location.reload(); }
+    else { alert('Check failed: ' + (j.error || res.status)); $('#checkAll').textContent = '🔎 Check all recalls'; $('#checkAll').disabled = false; }
+  });
+  document.querySelectorAll('[data-action]').forEach((btn) => {
+    btn.addEventListener('click', async () => {
+      const id = btn.dataset.id, action = btn.dataset.action;
+      if (action === 'delete') { if (!confirm('Delete this medication?')) return; const r = await fetch(`/api/medications/${id}/delete`, { method: 'POST' }); return r.ok ? location.reload() : alert('Delete failed'); }
+      btn.textContent = 'Checking…'; btn.disabled = true;
+      const r = await fetch(`/api/medications/${id}/check-recalls`, { method: 'POST' });
+      const j = await r.json().catch(() => ({}));
+      if (r.ok) { alert(`${j.name}: ${j.hits} FDA recall record(s) found.`); location.reload(); }
+      else { alert('Check failed: ' + (j.error || r.status)); btn.textContent = 'Check recalls'; btn.disabled = false; }
+    });
+  });
+</script>
+
+<%- include('partials/footer', { gridScript: true }) %>
diff --git a/views/partials/header.ejs b/views/partials/header.ejs
index 214e8a1..a6bc282 100644
--- a/views/partials/header.ejs
+++ b/views/partials/header.ejs
@@ -26,6 +26,8 @@
       <a href="/bills">Bills</a>
       <a href="/warranties">Warranties</a>
       <a href="/reorders">Reorders</a>
+      <a href="/medications">Meds</a>
+      <a href="/household">Household</a>
       <a href="/purchases">Purchases</a>
       <a href="/claims">Claims</a>
       <a href="/recalls">Recalls</a>

← ab337c2 fix(tests): isolate suite to abrams_os_test DB  ·  back to AbramsOS  ·  auto-save: 2026-07-08T14:36:14 (5 files) — lib/ids.js db/mig 6589647 →