Back

health.papp

All 280 lines. No libraries, nothing hidden. Read it before you run it.

health.papp
<!doctype html>
<meta charset="utf-8">
<meta name="papp-write" content="./health.json">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0.5 0.5 64 64'><g fill='%23111114'><circle cx='7' cy='40' r='4'/><circle cx='15' cy='51' r='4'/><circle cx='22' cy='31' r='4'/><circle cx='31' cy='14' r='4'/><circle cx='39' cy='35' r='4'/><circle cx='48' cy='45' r='4'/><circle cx='57' cy='22' r='4'/></g><path d='M5 47C16 47 20 24 30 24S42 40 50 36 56 26 60 24' fill='none' stroke='%23ff4f00' stroke-width='5' stroke-linecap='round'/></svg>">
<!--
  Health trends: years of Apple Health data, one year at a time.

  On your iPhone, open Health, tap your picture, then Export All Health Data. Unzip export.zip next to this page:

    health.papp
    apple_health_export/export.xml

  It reads export.xml in 8 MB pieces, so a 4 GB export is fine, and keeps a total per day of steps, sleep and
  resting heart rate in health.json. The next open reads only that, until the export changes.
  An iPhone and a Watch both count your steps: each day takes the one source that counted the most, not their sum.
-->
<title>Health trends</title>
<style>
  :root { color-scheme: light dark; --paper: light-dark(#fff, #0f0f11); --well: light-dark(#f4f4f5, #1c1c20); --ink: light-dark(#0f0f11, #f2f2f3); --soft: light-dark(#6b6b72, #9b9ba4); --rule: light-dark(#e4e4e7, #2b2b31); --accent: #ff4f00; }
  body { max-width: 960px; margin: 0 auto; padding: 40px 28px; background: var(--paper); color: var(--ink); font: 14px/1.45 system-ui, sans-serif; font-variant-numeric: tabular-nums; }
  [hidden] { display: none !important; }
  header { display: flex; flex-wrap: wrap; gap: 8px 24px; align-items: baseline; justify-content: space-between; margin-bottom: 24px; }
  h1 { margin: 0; font-size: 22px; font-weight: 600; letter-spacing: -0.01em; }
  header p { margin: 4px 0 0; color: var(--soft); }
  h2 { margin: 40px 0 12px; color: var(--soft); font-size: 12px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; }
  nav { display: flex; flex-wrap: wrap; gap: 6px; }
  button { padding: 5px 12px; border: 1px solid var(--rule); border-radius: 99px; background: none; color: var(--ink); font: inherit; font-size: 13px; cursor: pointer; }
  button:hover { border-color: var(--ink); }
  button[aria-pressed='true'] { border-color: var(--ink); background: var(--ink); color: var(--paper); }
  .metric { padding: 16px 0 8px; border-top: 1px solid var(--rule); }
  .metric p { display: flex; gap: 10px; align-items: baseline; margin: 0 0 8px; }
  .metric b { font-size: 12px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; }
  .metric strong { margin-left: auto; font-size: 20px; font-weight: 600; }
  .metric span, .none { color: var(--soft); }
  svg { display: block; width: 100%; height: auto; overflow: visible; }
  svg text { fill: var(--soft); font-size: 11px; }
  .grid { stroke: var(--rule); }
  .dot { fill: var(--ink); fill-opacity: 0.2; }
  .dot:hover { fill: var(--accent); fill-opacity: 1; }
  .trend { fill: none; stroke: var(--accent); stroke-width: 2; stroke-linejoin: round; }
  table { width: 100%; border-collapse: collapse; }
  th, td { padding: 8px 12px; border-bottom: 1px solid var(--rule); text-align: right; }
  th:first-child, td:first-child { text-align: left; }
  th { color: var(--soft); font-size: 12px; font-weight: 500; }
  tbody tr { cursor: pointer; }
  tbody tr:hover { background: var(--well); }
  tr.on td:first-child { box-shadow: inset 3px 0 var(--accent); font-weight: 600; }
  #weeks { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
  .card { padding: 14px 16px; border: 1px solid var(--rule); border-radius: 12px; }
  .card b { display: block; margin-bottom: 6px; font-weight: 600; }
  .card small { display: block; margin-top: 10px; color: var(--soft); font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; }
  .card p { display: flex; justify-content: space-between; margin: 0; padding: 3px 0; }
  .best p:first-of-type span:last-child { box-shadow: inset 0 -2px var(--accent); }
  .track { height: 10px; margin: 14px 0 10px; border-radius: 99px; background: var(--well); }
  #fill { display: block; width: 0; height: 100%; border-radius: inherit; background: var(--accent); }
  #loading p, #empty p, #empty li { color: var(--soft); }
  #empty { max-width: 520px; }
  #empty ol { padding-left: 20px; line-height: 1.7; }
  code { font: 12px ui-monospace, monospace; color: var(--ink); }
</style>
<header>
  <div><h1>Health trends</h1><p id="source"></p></div>
  <nav id="years"></nav>
</header>

<section id="empty" hidden>
  <p>No Apple Health export next to this app yet. To make one:</p>
  <ol>
    <li>On your iPhone, open the Health app and tap your picture at the top right.</li>
    <li>Tap Export All Health Data. It can take a few minutes, and the file can be large.</li>
    <li>Send <code>export.zip</code> to this computer, AirDrop works, and unzip it in this app's folder.</li>
    <li>You should now have <code>apple_health_export/export.xml</code> next to <code>health.papp</code>.</li>
  </ol>
  <button id="again">Look again</button>
</section>

<section id="loading" hidden>
  <p>Reading export.xml. This happens once; the next open is instant.</p>
  <div class="track"><span id="fill"></span></div>
  <p id="read"></p>
</section>

<main id="report" hidden>
  <div id="charts"></div>
  <h2>Year over year</h2>
  <table>
    <thead><tr><th>Year</th><th>Steps a day</th><th>Sleep a night</th><th>Resting heart rate</th><th>Days</th></tr></thead>
    <tbody id="table"></tbody>
  </table>
  <h2 id="weeksHeading"></h2>
  <div id="weeks"></div>
</main>

<script type="module">
  const exportPath = 'apple_health_export/export.xml';
  const chunk = 8 << 20;
  const day = 864e5;
  const hm = minutes => `${Math.floor(Math.round(minutes) / 60)} h ${String(Math.round(minutes) % 60).padStart(2, '0')}`;
  const signed = (number, text) => `${!/[1-9]/.test(text) ? '±' : number < 0 ? '−' : '+'}${text}`;
  // up: 1 when more is better. zero: the chart's scale starts at 0.
  const metrics = [
    { name: 'Steps', per: 'a day', up: 1, zero: true, show: v => Math.round(v).toLocaleString('en'), tick: v => (v ? `${Math.round(v / 1000)}k` : '0'), change: d => signed(d, Math.abs(Math.round(d)).toLocaleString('en')) },
    { name: 'Sleep', per: 'a night', up: 1, show: hm, tick: v => `${Math.round(v / 60)} h`, change: d => signed(d, `${Math.abs(Math.round(d))} min`) },
    { name: 'Resting heart rate', per: 'bpm', up: -1, show: v => v.toFixed(1), tick: v => Math.round(v), change: d => signed(d, Math.abs(d).toFixed(1)) },
  ];
  let days = {};
  let year;

  again.onclick = load;
  years.onclick = ({ target }) => {
    if (target.dataset.year) pick(+target.dataset.year);
  };
  table.onclick = ({ target }) => pick(+target.closest('tr').dataset.year);
  await load();

  // health.json holds the daily totals and the size and time of the export they came from.
  async function load() {
    const file = (await papp.exists(exportPath)) ? await papp.stat(exportPath) : null;
    const cache = (await papp.exists('health.json')) ? JSON.parse(await papp.readFile('health.json')) : null;
    if (!file && !cache) return show(empty);
    if (file && (file.size !== cache?.size || file.mtimeMs !== cache?.mtimeMs)) {
      show(loading);
      days = await parse(file.size, (done, records) => {
        fill.style.width = `${(done / file.size) * 100}%`;
        read.textContent = `${mb(done)} of ${mb(file.size)} · ${records.toLocaleString('en')} records so far`;
      });
      await papp.writeFile('health.json', JSON.stringify({ size: file.size, mtimeMs: file.mtimeMs, days }) + '\n');
    } else days = cache.days;
    const keys = Object.keys(days);
    if (!keys.length) {
      source.textContent = 'export.xml has no steps, sleep or resting heart rate.';
      return show(null);
    }
    source.textContent = `${keys.length.toLocaleString('en')} days, ${keys[0].slice(0, 4)} to ${keys.at(-1).slice(0, 4)} · from ${file ? `export.xml, ${mb(file.size)}` : 'health.json'}`;
    show(report);
    pick(+keys.at(-1).slice(0, 4));
  }

  // Streams the export: XML split by a chunk boundary waits in rest for the next chunk, and the decoder keeps a split character.
  async function parse(size, progress) {
    const decoder = new TextDecoder();
    const record = /<Record type="HK(?:Quantity|Category)TypeIdentifier(StepCount|SleepAnalysis|RestingHeartRate)"([^>]*)>/g;
    const field = / (sourceName|startDate|endDate|value)="([^"]*)"/g;
    const totals = {}; // date → { steps: { source: count }, sleep: { source: minutes }, heart: [sum, count] }
    let records = 0;
    let rest = '';
    const take = text => {
      for (const [, type, attributes] of text.matchAll(record)) {
        const { sourceName, startDate, endDate, value } = Object.fromEntries([...attributes.matchAll(field)].map(([, name, text]) => [name, text]));
        records++;
        if (type === 'StepCount') add(startDate.slice(0, 10), 'steps', sourceName, +value);
        // Asleep, AsleepCore, AsleepDeep, AsleepREM; not InBed or Awake. A night belongs to the morning it ends,
        // so sleep that ends after 6 pm counts toward the next day.
        else if (type === 'SleepAnalysis' && value.startsWith('HKCategoryValueSleepAnalysisAsleep')) {
          const night = +endDate.slice(11, 13) < 18 ? endDate.slice(0, 10) : new Date(Date.parse(endDate.slice(0, 10)) + day).toISOString().slice(0, 10);
          add(night, 'sleep', sourceName, (time(endDate) - time(startDate)) / 6e4);
        } else if (type === 'RestingHeartRate') {
          const heart = (totals[startDate.slice(0, 10)] ??= {}).heart ??= [0, 0];
          heart[0] += +value;
          heart[1]++;
        }
      }
    };
    const add = (date, kind, source, amount) => {
      const sources = (totals[date] ??= {})[kind] ??= {};
      sources[source] = (sources[source] ?? 0) + amount;
    };
    for (let offset = 0; offset < size; offset += chunk) {
      const bytes = await papp.readFile(exportPath, null, { offset, length: Math.min(chunk, size - offset) });
      const text = rest + decoder.decode(bytes, { stream: true });
      const cut = Math.max(text.lastIndexOf('<'), 0);
      take(text.slice(0, cut));
      rest = text.slice(cut);
      progress(offset + bytes.length, records);
    }
    take(rest + decoder.decode());
    // Per day, the source that counted the most: an iPhone and a Watch worn together count the same steps twice.
    const most = sources => (sources ? Math.round(Math.max(...Object.values(sources))) : null);
    return Object.fromEntries(Object.keys(totals).sort().map(date => {
      const { steps, sleep, heart } = totals[date];
      return [date, [most(steps), most(sleep), heart ? Math.round((heart[0] / heart[1]) * 10) / 10 : null]];
    }));
  }

  // "2026-03-29 07:12:00 +0200" as milliseconds.
  function time(text) {
    return Date.parse(`${text.slice(0, 10)}T${text.slice(11, 19)}${text.slice(20, 23)}:${text.slice(23, 25)}`);
  }

  function show(section) {
    for (const element of [empty, loading, report]) element.hidden = element !== section;
    years.hidden = section !== report;
  }

  function pick(chosen) {
    year = chosen;
    const all = [...new Set(Object.keys(days).map(date => +date.slice(0, 4)))];
    years.innerHTML = all.map(each => `<button data-year="${each}" aria-pressed="${each === year}">${each}</button>`).join('');
    charts.innerHTML = metrics.map(chart).join('');
    table.innerHTML = all.toReversed().map(each => `
      <tr data-year="${each}" class="${each === year ? 'on' : ''}">
        <td>${each}</td>
        ${metrics.map((metric, index) => {
          const mean = average(each, index);
          return `<td>${mean === null ? '–' : metric.show(mean)}</td>`;
        }).join('')}
        <td>${Object.keys(days).filter(date => date.startsWith(each)).length}</td>
      </tr>`).join('');
    weeksHeading.textContent = `Best and worst weeks of ${year}`;
    weeks.innerHTML = metrics.map(ranked).join('');
  }

  function value(t, index) {
    return days[new Date(t).toISOString().slice(0, 10)]?.[index] ?? null;
  }

  function average(of, index) {
    const values = Object.entries(days).filter(([date, row]) => date.startsWith(of) && row[index] != null).map(([, row]) => row[index]);
    return values.length ? values.reduce((sum, v) => sum + v, 0) / values.length : null;
  }

  // A dot per day and the average of the 30 days up to each day, on one year.
  function chart(metric, index) {
    const [width, height, left, bottom] = [880, 150, 40, 20];
    const start = Date.UTC(year, 0, 1);
    const end = Date.UTC(year + 1, 0, 1);
    const points = [];
    for (let t = start; t < end; t += day) if (value(t, index) != null) points.push([t, value(t, index)]);
    const now = average(year, index);
    const before = average(year - 1, index);
    const head = `<p><b>${metric.name}</b><strong>${now == null ? '–' : metric.show(now)}</strong><span>${metric.per}</span><span>${before == null || now == null ? '' : `${metric.change(now - before)} on ${year - 1}`}</span></p>`;
    if (!points.length) return `<section class="metric">${head}<p class="none">No ${metric.name.toLowerCase()} in ${year}.</p></section>`;

    // The scale skips the odd extreme day, so one 40,000-step hike doesn't flatten the rest.
    const sorted = points.map(([, v]) => v).sort((a, b) => a - b);
    const low = metric.zero ? 0 : sorted[Math.floor(sorted.length * 0.01)] * 0.95;
    const high = sorted[Math.ceil(sorted.length * 0.99) - 1] * 1.05 || 1;
    const x = t => (left + ((t - start) / (end - start)) * (width - left)).toFixed(1);
    const y = v => (height - bottom - ((Math.min(Math.max(v, low), high) - low) / (high - low)) * (height - bottom - 8)).toFixed(1);

    let line = '';
    let pen = false;
    let [sum, count] = [0, 0];
    const first = start - 30 * day;
    for (let t = first; t <= points.at(-1)[0]; t += day) {
      if (value(t, index) != null) [sum, count] = [sum + value(t, index), count + 1];
      if (t - 30 * day >= first && value(t - 30 * day, index) != null) [sum, count] = [sum - value(t - 30 * day, index), count - 1];
      if (t < start) continue;
      line += count >= 7 ? `${pen ? 'L' : 'M'}${x(t)},${y(sum / count)}` : '';
      pen = count >= 7;
    }

    const ticks = [low, (low + high) / 2, high].map(v => `<line class="grid" x1="${left}" x2="${width}" y1="${y(v)}" y2="${y(v)}"/><text x="0" y="${+y(v) + 4}">${metric.tick(v)}</text>`);
    const months = Array.from({ length: 12 }, (_, month) => `<text x="${x(Date.UTC(year, month, 1))}" y="${height}">${new Date(Date.UTC(2000, month)).toLocaleString('en', { month: 'short', timeZone: 'UTC' })}</text>`);
    const dots = points.map(([t, v]) => `<circle class="dot" cx="${x(t)}" cy="${y(v)}" r="2.4"><title>${label(t)} · ${metric.show(v)}</title></circle>`);
    return `<section class="metric">${head}<svg viewBox="0 0 ${width} ${height}">${ticks.join('')}${months.join('')}${dots.join('')}<path class="trend" d="${line}"/></svg></section>`;
  }

  // Weeks run Monday to Sunday; a week counts if it has at least 4 days of data.
  function ranked(metric, index) {
    const scored = [];
    const jan1 = Date.UTC(year, 0, 1);
    for (let monday = jan1 + ((8 - new Date(jan1).getUTCDay()) % 7) * day; monday < Date.UTC(year + 1, 0, 1); monday += 7 * day) {
      const values = Array.from({ length: 7 }, (_, n) => value(monday + n * day, index)).filter(v => v != null);
      if (values.length >= 4) scored.push([monday, values.reduce((sum, v) => sum + v, 0) / values.length]);
    }
    scored.sort((a, b) => (b[1] - a[1]) * metric.up);
    const rows = list => list.map(([monday, v]) => `<p><span>${label(monday)} – ${label(monday + 6 * day)}</span><span>${metric.show(v)}</span></p>`).join('');
    if (scored.length < 6) return `<div class="card"><b>${metric.name}</b><p class="none">Not enough weeks in ${year}.</p></div>`;
    return `<div class="card"><b>${metric.name}</b><div class="best"><small>Best</small>${rows(scored.slice(0, 3))}</div><small>Worst</small>${rows(scored.slice(-3).reverse())}</div>`;
  }

  function label(t) {
    return `${new Date(t).getUTCDate()} ${new Date(t).toLocaleString('en', { month: 'short', timeZone: 'UTC' })}`;
  }

  function mb(bytes) {
    return `${Math.round(bytes / 1e6).toLocaleString('en')} MB`;
  }
</script>
Download health.papp