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

Step 2: Redact Sensitive Data

Step 3: Configure Provider

Step 4: LLM Extraction

Step 5: Validation

Step 6: Dashboard

Extraction Prompt

The prompt instructs the LLM to:

  1. Treat the attached document(s) as blood test laboratory reports.
  2. Extract every measurable metric it can identify.
  3. Return a JSON object { "entries": [{ ... }] } with exactly one entry — each file is sent in a separate LLM call.
  4. Only include schema fields where a numeric value was actually measured — omit all fields with no data.
  5. 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).
  6. For metrics with a real numeric value that have no matching schema field, add them to unknown_metrics with: name (English), value (number), unit (display label e.g. "mmol/l"), raw_text. Never add null-valued or not-found metrics to unknown_metrics.
  7. 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)

  1. Header: title + timespan summary + action buttons (Download CSV, Add report, Clear all)
  2. Markers: All metrics grouped by category, each with sparkline and expandable history
  3. Timetable: All measurement dates as columns, markers as rows, with category filter
  4. Key Highlights & Clinical Summary: Non-OK markers with findings, status badges, and recommendations
  5. Unknown Metrics: Metrics extracted by LLM but not in the schema
  6. Footer: Disclaimer — "Not medical advice. Consult a physician for clinical decisions."

Markers Section

Each marker row shows:

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

Key Highlights & Clinical Summary

Unknown Metrics Panel

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.

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

Redaction Screen

Configure Screen

Processing Screen

Error Screen

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

Theming (daisyUI v5)

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

Key Functions

extractTextFromPDFs(files)

buildPrompt(text)

callLLM(provider, apiKey, text)

parseAndValidate(responseText)

getMarkerValue(entry, m)

getMarkerAltValue(entry, m)

statusTextCls(status)

statusBadgeCls(status)

statusLabelText(status)

findingBadgeCls(status)

buildSparklineSVG(m, ents)

downloadCSV(ents)

el(tag, attrs, ...children)

solidBtn(label, onClick) / ghostBtn(label, onClick)

fmtDateShort(dateStr)

mergeEntry(ents, newEntry)

User Workflows

First-Time Upload Workflow

  1. User opens tool.
  2. User drags or selects one or more PDF blood test reports.
  3. User optionally selects text and presses R to redact spans; clicks "Confirm & Continue →".
  4. User selects provider; enters API key if not yet stored.
  5. Tool sends extracted text + prompt to the LLM.
  6. Loading screen shown while awaiting response.
  7. Response is parsed and validated.
  8. Dashboard renders with all metrics, grouped by category.
  9. Unknown metrics are listed below the dashboard.

Returning User Workflow

  1. User opens tool; existing entries render the dashboard immediately.
  2. User clicks "+ Add report" to upload new PDFs.
  3. Steps 3–9 as above, skipping the API key entry if key is already stored.

Error Recovery Workflow

  1. LLM call fails (e.g. wrong API key, rate limit, provider tier issue).
  2. Error screen shows the error message.
  3. User clicks "Try different provider →" to return to the configure screen with redacted text preserved — no need to re-upload PDFs.
  4. Alternatively, "← Start over" returns to the upload screen.