health.papp
All 280 lines. No libraries, nothing hidden. Read it before you run it.
<!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>