Health Metrics Dashboard
A tool to upload blood test PDF reports, redact sensitive data, extract metrics via an LLM, and visualize results in a structured health dashboard.
Purpose
Upload one or more blood test PDFs, optionally redact sensitive data, send to an LLM (OpenAI, Anthropic, or Fireworks) for structured extraction, validate the output, and render a multi-category health dashboard with historical comparisons.
Workflow
Step 1: Upload PDFs
- User uploads one or more blood test PDF reports via a file input or drag-and-drop zone.
- Uploaded files are listed with filename and size.
- Files are read in the browser (no server upload).
Step 2: Redact Sensitive Data
- PDF text is extracted first (via PDF.js) and shown in a plain-text view.
- The user selects text spans to redact; selected spans are replaced with
[REDACTED]inline. - Keyboard shortcut: press
Rto redact the current selection. - Undo stack allows reverting individual redactions before confirming.
- This step is optional — a "Skip" button advances without redacting.
- The redacted plain text is what gets sent to the LLM — no PDF rendering or coordinate mapping involved.
Step 3: Configure Provider
- User selects provider: Anthropic, OpenAI, or Fireworks.
- Provider selection is persisted to
localStorageunderhealth_provider. - If no API key exists for the selected provider, an inline key entry section appears.
- Keys are stored in
localStorageunder{provider}_api_key.
Step 4: LLM Extraction
- PDF content (post-redaction) is sent to the selected LLM with the extraction prompt (see below).
- The LLM returns a structured JSON object conforming to the metrics schema.
- Metrics not in the schema are surfaced separately so the user can consider extending the schema.
Step 5: Validation
- The LLM response is validated against the schema using Valibot.
- Validation errors are shown in the error screen.
- Valid entries are accepted and stored in the session.
Step 6: Dashboard
- All validated measurement entries are rendered in the dashboard.
- The dashboard builds a historical record across uploads and page loads (see Session & Persistence below).
Extraction Prompt
The prompt instructs the LLM to:
- Treat the attached document(s) as blood test laboratory reports.
- Extract every measurable metric it can identify.
- Return a JSON object
{ "entries": [{ ... }] }with exactly one entry — each file is sent in a separate LLM call. - Only include schema fields where a numeric value was actually measured — omit all fields with no data.
- Translate metric names to their English schema key equivalents. The prompt includes a comprehensive mapping table covering German CBC, differential, electrolyte, and metabolic terms (e.g. Thrombozyten→thrombocytes, Hämatokrit→hematocrit, berechnete GFR→gfr, Blutsenkung→esr).
- For metrics with a real numeric value that have no matching schema field, add them to
unknown_metricswith:name(English),value(number),unit(display label e.g."mmol/l"),raw_text. Never add null-valued or not-found metrics tounknown_metrics. - Return valid JSON only, no prose.
Metrics Schema
{
"type": "object",
"properties": {
"date": { "type": "string", "format": "date" },
"zinc": {
"type": "object",
"properties": {
"umol_l": { "type": ["number", "null"] }
}
},
"total_testosterone": {
"type": "object",
"properties": {
"nmol_l": { "type": ["number", "null"] },
"ng_ml": { "type": ["number", "null"] }
}
},
"free_testosterone": {
"type": "object",
"properties": {
"pmol_l": { "type": ["number", "null"] }
}
},
"shbg": {
"type": "object",
"properties": {
"nmol_l": { "type": ["number", "null"] }
}
},
"tsh": {
"type": "object",
"properties": {
"uU_ml": { "type": ["number", "null"] }
}
},
"free_t4": {
"type": "object",
"properties": {
"ng_dl": { "type": ["number", "null"] },
"pmol_l": { "type": ["number", "null"] }
}
},
"free_t3": {
"type": "object",
"properties": {
"pg_ml": { "type": ["number", "null"] },
"pmol_l": { "type": ["number", "null"] }
}
},
"folate": {
"type": "object",
"properties": {
"ng_ml": { "type": ["number", "null"] },
"nmol_l": { "type": ["number", "null"] }
}
},
"vitamin_b12": {
"type": "object",
"properties": {
"pg_ml": { "type": ["number", "null"] },
"pmol_l": { "type": ["number", "null"] }
}
},
"vitamin_d_25_oh": {
"type": "object",
"properties": {
"ng_ml": { "type": ["number", "null"] },
"nmol_l": { "type": ["number", "null"] }
}
},
"cholesterol": {
"type": "object",
"properties": {
"mg_dl": { "type": ["number", "null"] },
"mmol_l": { "type": ["number", "null"] }
}
},
"cholesterol_hdl_ratio": {
"type": ["number", "null"]
},
"hdl": {
"type": "object",
"properties": {
"mg_dl": { "type": ["number", "null"] },
"mmol_l": { "type": ["number", "null"] }
}
},
"ldl": {
"type": "object",
"properties": {
"mg_dl": { "type": ["number", "null"] },
"mmol_l": { "type": ["number", "null"] }
}
},
"triglycerides": {
"type": "object",
"properties": {
"mg_dl": { "type": ["number", "null"] },
"mmol_l": { "type": ["number", "null"] }
}
},
"tg_hdl_ratio": {
"type": ["number", "null"]
},
"free_androgen_index": {
"type": "object",
"properties": {
"percent": { "type": ["number", "null"] }
}
},
"luteinizing_hormone": {
"type": "object",
"properties": {
"iu_l": { "type": ["number", "null"] }
}
},
"fsh": {
"type": "object",
"properties": {
"iu_l": { "type": ["number", "null"] }
}
},
"potassium": {
"type": "object",
"properties": {
"mmol_l": { "type": ["number", "null"] }
}
},
"calcium": {
"type": "object",
"properties": {
"mmol_l": { "type": ["number", "null"] }
}
},
"magnesium": {
"type": "object",
"properties": {
"mmol_l": { "type": ["number", "null"] }
}
},
"creatinine": {
"type": "object",
"properties": {
"mg_dl": { "type": ["number", "null"] },
"umol_l": { "type": ["number", "null"] }
}
},
"gfr": {
"type": "object",
"properties": {
"ml_min": { "type": ["number", "null"] }
}
},
"bun": {
"type": "object",
"properties": {
"mg_dl": { "type": ["number", "null"] },
"mmol_l": { "type": ["number", "null"] }
}
},
"uric_acid": {
"type": "object",
"properties": {
"mg_dl": { "type": ["number", "null"] },
"umol_l": { "type": ["number", "null"] }
}
},
"asat_got": {
"type": "object",
"properties": {
"u_l": { "type": ["number", "null"] }
}
},
"alat_gpt": {
"type": "object",
"properties": {
"u_l": { "type": ["number", "null"] }
}
},
"gamma_gt": {
"type": "object",
"properties": {
"u_l": { "type": ["number", "null"] }
}
},
"bilirubin": {
"type": "object",
"properties": {
"mg_dl": { "type": ["number", "null"] },
"umol_l": { "type": ["number", "null"] }
}
},
"lipase": {
"type": "object",
"properties": {
"u_l": { "type": ["number", "null"] }
}
},
"alpha_amylase": {
"type": "object",
"properties": {
"u_l": { "type": ["number", "null"] }
}
},
"iron": {
"type": "object",
"properties": {
"ug_dl": { "type": ["number", "null"] },
"umol_l": { "type": ["number", "null"] }
}
},
"transferrin": {
"type": "object",
"properties": {
"g_l": { "type": ["number", "null"] }
}
},
"transferrin_saturation": {
"type": "object",
"properties": {
"percent": { "type": ["number", "null"] }
}
},
"ferritin": {
"type": "object",
"properties": {
"ng_ml": { "type": ["number", "null"] }
}
},
"glucose": {
"type": "object",
"properties": {
"mg_dl": { "type": ["number", "null"] },
"mmol_l": { "type": ["number", "null"] }
}
},
"total_protein": {
"type": "object",
"properties": {
"g_l": { "type": ["number", "null"] }
}
},
"albumin": {
"type": "object",
"properties": {
"g_l": { "type": ["number", "null"] }
}
}
},
"required": ["date"]
}
Unknown metrics returned by the LLM are collected in:
{
"unknown_metrics": [
{ "name": "string", "value": "number|null", "unit": "string|null", "raw_text": "string|null" }
]
}
These are surfaced to the user with a prompt to consider extending the schema.
Marker Definitions
All markers are defined in a flat MARKER_DEFS array. Each entry has:
| Field | Description |
|---|---|
key |
Property key in the extracted JSON |
name |
Display name |
desc |
Short plain-English description shown as a tooltip on hover |
pu |
Primary unit key (e.g. nmol_l); null for scalar metrics |
pl |
Primary unit label (e.g. nmol/l) |
au |
Alt unit key (optional) |
al |
Alt unit label (optional) |
ref |
Reference range string for display |
cat |
Category name |
check(v) |
Returns 'ok', 'borderline', 'high', 'low', or null (for missing values) |
CATEGORIES is derived from MARKER_DEFS — it is not declared separately:
const CATEGORIES = CATEGORY_NAMES.map(name => ({ name, markers: MARKER_DEFS.filter(m => m.cat === name) }))
Metric Categories
| Category | Metrics |
|---|---|
| Blood Count | Leukocytes, Thrombocytes, Erythrocytes, Hemoglobin, Hematocrit, MCV, MCH, MCHC, RDW-CV, Neutrophils (abs+rel), Lymphocytes (abs+rel), Monocytes (abs+rel), Eosinophils (abs+rel), Basophils (abs+rel) |
| Hormones | Total Testosterone, Free Testosterone, SHBG, Free Androgen Index, LH, FSH, PSA, DHEAS, Bioavailable Testosterone |
| Thyroid | TSH, Free T4, Free T3 |
| Vitamins & Minerals | Zinc, Folate, Vitamin B12, Vitamin D (25-OH), Sodium, Potassium, Calcium, Magnesium |
| Cholesterol & Lipids | Cholesterol, HDL, LDL, Triglycerides, Chol/HDL Ratio, TG:HDL Ratio, Lipoprotein(a) |
| Metabolic & Kidney | Glucose, Creatinine, GFR, BUN, Uric Acid |
| Liver Function & Other | ASAT/GOT, ALAT/GPT, Gamma-GT, Bilirubin, Lipase, Alpha-Amylase, Iron, Transferrin, Transferrin Saturation, Ferritin, Total Protein, Albumin, CRP, ESR 1h, ESR 2h |
Dashboard
Page Structure (top to bottom)
- Header: title + timespan summary + action buttons (Download CSV, Add report, Clear all)
- Markers: All metrics grouped by category, each with sparkline and expandable history
- Timetable: All measurement dates as columns, markers as rows, with category filter
- Key Highlights & Clinical Summary: Non-OK markers with findings, status badges, and recommendations
- Unknown Metrics: Metrics extracted by LLM but not in the schema
- Footer: Disclaimer — "Not medical advice. Consult a physician for clinical decisions."
Markers Section
Each marker row shows:
- Marker name (wrapped in a daisyUI
tooltip tooltip-rightwhenm.descis present) + primary unit label - Inline sparkline SVG (80×24 px) showing trend across all measurement dates
- Latest value (colored by status) + alt unit below if available
- Reference range (hidden on small screens)
- Status badge:
ok,~,high, orlow - Chevron (
▸/▾) indicating expand/collapse state
Clicking a row toggles an expand panel showing all historical measurements per date, plus the reference range. Expand state is tracked in a module-level expandState object (not a signal), so rows stay open across dashboard re-renders.
Timetable
- daisyUI
table table-xswith monospace font - First column: marker name (wrapped in a daisyUI
tooltip tooltip-rightwhenm.descis present) + unit; subsequent columns: one per measurement date (formatted asMon 'YY) - Category filter via
<select select-xs>— directly rebuilds rows viabuildRows(), no signal - Category group separator: bold top border on first row of each category, category name shown in the name cell
- Color legend below the table
Key Highlights & Clinical Summary
- daisyUI
table table-smwith monospace font - Shows only non-OK markers from the latest entry
- Columns: Category, Finding, Status, Note
- Note column states the status factually with the reference range (e.g. "LDL is above the reference range (<3.37 mmol/l).")
- Status badges: green (Optimal/Normal), amber (Watch), red (Elevated/Low)
- If all markers are in range, shows a green "All measured markers within reference range." message
Unknown Metrics Panel
- daisyUI
table table-xs - Columns: Name, Value, Unit, Raw text
- Accompanied by a note suggesting the user extend the schema
Session & Persistence
A "session" spans multiple page loads. All validated measurement entries are persisted in localStorage under the key health_metrics_entries as a JSON array, sorted by date.
- PDFs are never stored — only the validated, structured data extracted from them.
- Each entry is one measurement date's worth of data, conforming to the metrics schema.
- On page load, entries are read from
localStorageand the dashboard renders immediately if any exist. - On new upload, the newly extracted and validated entry is merged into the stored array (de-duplicated by date), then written back to
localStorage. - Manual deletion: not currently supported per-entry; use "Clear all" to wipe all measurements.
- Clear all: a destructive action to wipe
localStorageand reset the dashboard, with aconfirm()prompt.
The localStorage limit (~5 MB) is not a concern for this data shape — dozens of annual blood test entries with ~40 numeric fields each amount to a few kilobytes.
UI Components
Upload Screen
- Header with title
- Drag-and-drop zone or file picker (
accept=".pdf", multiple files allowed) - File list with name, size, and remove button per file
- "Extract & Continue →" button — extracts PDF text, then advances to redaction
- If existing entries exist, a "Back to dashboard" link appears below
Redaction Screen
- Header with title
- Extracted text in a scrollable
<pre>withselect-textenabled - Search input + "Redact all (Enter)" button replaces every occurrence of the search term with
[REDACTED]; Enter key triggers it from the input - "Redact selection (R)" button +
Rkeyboard shortcut replace selection with[REDACTED] - "Undo" button to revert last redaction (covers both search-replace and selection-redact)
- "Skip" button to advance without redacting
- "Confirm & Continue →" button to finalize and advance to provider selection
Configure Screen
- Header with title
- Provider toggle: Anthropic / OpenAI / Fireworks (active =
btn-neutral, inactive =btn-outline) - Selected provider stored in
localStorageunderhealth_provider - If no API key for the selected provider: inline section with password input + "Save & Run →" button appears
- "Run Extraction →" button starts the extraction if a key is already stored
Processing Screen
- Header with title
- Centered loading message with animated pulse bar
Error Screen
- Header with title
- Error message in red
- Two recovery buttons:
- "Try different provider →" → navigates to
configuringstate (redacted text preserved) - "← Start over" → navigates to
uploadstate
- "Try different provider →" → navigates to
Dashboard Screen
See Dashboard section above.
Dependencies
| Library | Version | How loaded | Purpose |
|---|---|---|---|
| @tailwindcss/browser | 4 | <script src> CDN |
Utility CSS |
| daisyui | 5 | <link> CDN (themes.css + main) |
Theme system + component classes |
| spellcaster | 6.0.0 | importmap (esm.sh) | Reactive signals + effects |
| pdfjs-dist | 4.9.155 | importmap (esm.sh) | PDF text extraction in browser |
| valibot | 1 | importmap (esm.sh) | Schema validation of LLM output |
daisyUI is loaded via two <link> tags, not importmap:
<link href="https://cdn.jsdelivr.net/npm/daisyui@5/themes.css" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" />
Visual Language
- Minimal, text-first interface with almost no decorative chrome.
- Monospace-led typography (
ui-monospace) applied globally viabodystyle. - daisyUI semantic color tokens (
bg-base-100,text-base-content, etc.) throughout — no raw color values in layout. - High whitespace density; structural clarity through alignment and spacing over cards/shadows.
- Motion: minimal —
.sparkline-dothover transitions,.marker-rowbackground transitions.
Theming (daisyUI v5)
data-themeattribute on<html>controls the active theme (lightordark).- Theme is always in sync with the system
prefers-color-scheme— no manual override. - An inline
<script>in<head>setsdata-themebefore first paint to avoid FOUC. - The module script adds a
changelistener onmatchMediato updatedata-themelive when the OS theme changes mid-session. - Tailwind dark-mode variant:
@variant dark (&:where([data-theme="dark"], [data-theme="dark"] *))— enablesdark:utilities to respond todata-theme="dark"rather than a CSS class.
Status Colors
| Status | Text color | Badge |
|---|---|---|
ok |
green-600 / dark:green-400 | green-100/green-700 |
borderline |
amber-500 | amber-100/amber-700 |
high / low |
red-500 | red-100/red-700 |
null |
base-content/50 | base-content/20 |
Sparklines
- 80×24 px inline SVG per marker row.
- Polyline connecting all data points (subtle, 15% opacity).
- Colored dots:
#22c55e(ok),#f59e0b(borderline),#ef4444(high/low),#94a3b8(no data). - Latest measurement dot is rendered at radius 3; historical at radius 2.
.sparkline-dotclass has a0.15sradius transition for hover effects.
Key Functions
extractTextFromPDFs(files)
- Accepts
FileList, reads each asArrayBuffer. - Uses PDF.js
page.getTextContent()per page. - Joins pages within each file with
\n\n---\n\n. - Joins files with
====FILE====separator sorunExtractioncan split them for separate LLM calls. - Returns full combined string.
buildPrompt(text)
- Constructs the LLM user prompt.
- Inlines
SCHEMA_SUMMARY(derived at runtime fromMARKER_DEFS) and the extracted text. - Instructs model to omit fields with no data (reduces response token count) and return JSON only.
callLLM(provider, apiKey, text)
- Dispatches to the appropriate API based on
provider:openai→gpt-4owithresponse_format: { type: 'json_object' }anthropic→claude-opus-4-6,max_tokens: 4096, requiresanthropic-dangerous-direct-browser-access: trueheaderfireworks→llama-v3p3-70b-instructvia OpenAI-compatible endpoint,max_tokens: 4096(non-streaming cap)
- Returns raw response string.
parseAndValidate(responseText)
- Strips markdown code fences if present, then JSON-parses.
- Validates against
metricsSchema(Valibot). - Logs validation errors to console with full raw response.
- Returns
{ data, unknownMetrics, errors }.
getMarkerValue(entry, m)
- Returns the primary unit value for a marker from a given entry.
- Handles scalar markers (direct number) and object markers (
entry[m.key][m.pu]).
getMarkerAltValue(entry, m)
- Returns the alt unit value for a marker (if
m.auis defined).
statusTextCls(status)
- Maps
ok/borderline/high/low/nullto Tailwind text color classes.
statusBadgeCls(status)
- Maps status to badge background + text color classes.
statusLabelText(status)
- Returns short display string:
ok,~,high,low, or—.
findingBadgeCls(status)
- Used in Clinical Summary for free-text status strings from the generated findings.
- Maps
optimal/normal→ green,watch/monitoring/borderline→ amber,elevated/low/critical→ red.
buildSparklineSVG(m, ents)
- Builds an 80×24 inline SVG string for a marker across all entries.
- Normalizes y-axis to the observed value range.
- Returns placeholder SVG with
—if no data exists.
downloadCSV(ents)
- Converts all stored entries to a flat CSV (one row per date, one column per unit field).
- Both primary and alt unit columns are included for markers that have both.
- Triggers browser download of
health-metrics.csv.
el(tag, attrs, ...children)
- Minimal DOM element factory.
- Handles
className,innerHTML, event listeners (on*), and arbitrary attributes. - Accepts string children (converted to text nodes) and nested arrays.
solidBtn(label, onClick) / ghostBtn(label, onClick)
- Convenience wrappers for daisyUI
btn btn-neutralandbtn btn-ghost btn-smbuttons.
fmtDateShort(dateStr)
- Formats
YYYY-MM-DDasMon 'YY(e.g.Jan '24) for table column headers.
mergeEntry(ents, newEntry)
- De-duplicates by
date, then sorts by date ascending.
User Workflows
First-Time Upload Workflow
- User opens tool.
- User drags or selects one or more PDF blood test reports.
- User optionally selects text and presses
Rto redact spans; clicks "Confirm & Continue →". - User selects provider; enters API key if not yet stored.
- Tool sends extracted text + prompt to the LLM.
- Loading screen shown while awaiting response.
- Response is parsed and validated.
- Dashboard renders with all metrics, grouped by category.
- Unknown metrics are listed below the dashboard.
Returning User Workflow
- User opens tool; existing entries render the dashboard immediately.
- User clicks "+ Add report" to upload new PDFs.
- Steps 3–9 as above, skipping the API key entry if key is already stored.
Error Recovery Workflow
- LLM call fails (e.g. wrong API key, rate limit, provider tier issue).
- Error screen shows the error message.
- User clicks "Try different provider →" to return to the configure screen with redacted text preserved — no need to re-upload PDFs.
- Alternatively, "← Start over" returns to the upload screen.