Joel
Project Vyne
I’m Joel. Health nerd with a data problem, or a data nerd with a health problem. My wrist reports every minute, my blood once a year, my genome once ever. Three timescales, one root. The interesting part is where the branches touch.

At a glance

Current

Body map

Every marker on this page belongs to some part of you. Pick a region on the figure, or a system from the list, to see what the watch, the lab and the genome each have to say about it.

Hover the figure to find a region, or pick from the list

Training

Exercise pulled out of the general health metrics, because training load is a thing you steer week to week rather than a number you watch drift. Everything here comes from closed Apple Watch workouts and exercise minutes.

What moves together

Pearson correlation across the selected range. Sleep and training are compared against the following day's recovery markers, since that is the direction the effect runs. Correlation is not causation and a small n is noisy, so treat these as prompts, not conclusions. Anything involving sleep is weaker still: I charge my watch overnight most nights, so those rows rest on far fewer readings than the others, and on a set of nights that is not representative.

Off-baseline stretches

Resting heart rate, HRV, breathing rate and wrist temperature tend to move together when the body is under load it did not choose: an infection, a bad week, too much training. This flags stretches where at least two of them broke away from my own 60-day baseline at once. It is pattern-spotting after the fact, not a diagnosis, and it cannot tell one cause from another.

Bloodwork

Transcribed by hand from lab PDFs. Three draws so far, across two labs, which is why a few reference ranges shift between the first panel and the two after it. The shaded band on each chart is the lab's own reference range; the one exception is LDL, where the widely used desirable threshold of 100 is shaded instead of the lab's more permissive 130. I also take 7.5 g of creatine a day, which the kidney markers cannot be read without. The ⓘ on each of those three says why.

What I take

DNA

How it's built

A phone that refuses to answer questions, a key/value store, and one HTML file. No database, no framework, no build step. If you only came for the numbers you can stop here. This is the part where I explain the plumbing to nobody in particular.

The constraint everything else falls out of

There is no API for Apple Health. Not a locked one, not a paid one. None.

HealthKit lives on the phone, in an encrypted store, and nothing on a server can reach in and ask it anything. Not with my password, not with an OAuth token, not at all. Apple made that call deliberately and I think they were right, which is inconvenient of them.

So the data can't be pulled. It has to be pushed, by the phone, on a schedule it decides. Every other decision here is downstream of that one fact: why there's an ingest endpoint at all, why it has to be idempotent, why a missing day means "the phone didn't call" rather than "nothing happened".

The pipeline

iPhoneHealth Auto Export, hourly POST /api/ingestNetlify Function Netlify Blobskey/value, no schema GET /api/datapublic, cached 5 min this pagedraws it client-side

An iPhone app reads whatever HealthKit will hand over, wraps it in JSON and POSTs it to a serverless function that exists for the few hundred milliseconds it takes to run. That function writes to a key/value store I never had to configure. The page fetches the whole history back as one blob and draws every chart in the browser.

Total infrastructure: zero servers, zero databases, zero dollars a month. The most complex moving part is a phone automation with a toggle I got wrong for an entire evening (see below).

Ingest is boring on purpose

This is the one piece I'd be properly annoyed to get wrong, so it is the dullest code in the project. Four rules:

  • It merges, never replaces. Re-sending a day fills gaps in that day and leaves everything else untouched. A backfill can fail halfway, be re-run twice, arrive out of order, and the result is identical. Nothing here has a delete path.
  • It buckets by the phone's local day, not UTC: the first ten characters of the timestamp the phone sent, and that's the date. Convert to UTC first and half my late-night workouts move to tomorrow.
  • It prunes past five years and that is the only thing it throws away.
  • Auth is one shared token in a header, compared in constant time. No accounts, no sessions, no cookies. The entire threat model is "a stranger POSTs nonsense into my step count".

What deliberately doesn't get stored

Workouts arrive carrying routes. GPS traces. Where I run, when, how often, starting from my front door, which is a fairly complete description of where I live and when I'm not in it.

None of it is written. The normalizer reduces a workout to three numbers (count, minutes, energy) and drops everything else on the floor. There's a test that feeds it a payload stuffed with location data and asserts none of it survives, because "I'm fairly sure I handled that" is not a security model for a page anyone on the internet can read.

The front end is one file

index.html is around 2,900 lines and imports nothing. No React, no D3, no bundler, no npm install to deploy. Every chart on this page is SVG assembled by hand from the numbers.

That isn't asceticism, it's laziness with a long time horizon. I wanted to open one file in a year and understand all of it, and I wanted deploying to be "drag the folder, done". Colors are a palette checked for color-vision deficiency at every pair, and no status anywhere is signaled by color alone.

Netlify FunctionsNetlify BlobsHealth Auto Export vanilla JShand-rolled SVGzero dependencies

Three bugs I keep as souvenirs

Absence is not zerothe expensive one

Workouts and health metrics sync on separate schedules, so days regularly landed with workouts attached but no exercise-ring reading. The streak logic read exerciseMinutes ?? 0, scored those days as zero minutes, and broke a three-year streak on what was actually a sync gap.

The fix wasn't a better number, it was a third state. Every day is now worked, rest or unknown, and only a real ring reading can prove a short day. Missing data now looks like missing data everywhere on the page, which is the whole point.

Every date was one day earlyfor months

Dates were built at UTC midnight and then formatted in the reader's own timezone. In New York that lands on 7pm the previous evening. Every label on the page (axes, tooltips, the streak, the lab dates) was silently off by one, and nothing looked broken.

I found it only because the streak tile said "since Jan 18" while the underlying record said Jan 19. These are calendar days, not instants, so they're now formatted in UTC, with tests that run under five timezones and check a year boundary specifically.

I cannot drawfour attempts

The body map above took four goes. Hand-authored SVG paths: the legs read as stilts. Second attempt: a "waistband" seam where the torso met the legs. Third attempt, and I want to be clear that this happened: a signed-distance-field raymarcher in numpy, which fused both thighs into a skirt and rendered every muscle groove as a machined trench.

Then I gave up and used an MIT-licensed anatomical asset, which took about ten minutes and looks like a human being. Knowing when to stop building something is a skill. I am still working on it.

How it's tested

125 checks across four suites, covering the normalizer, the ingest endpoint, the training logic and the three derived sections. The front-end suites do something slightly unusual: rather than copying the functions, they parse index.html, slice the real source out of the script block by bracket-matching, and evaluate that. A copied test happily passes while the live page is broken. This one can't.

I also test the tests. Flip a threshold, delete a guard, scramble a lookup table, then confirm the suite actually goes red. A test that still passes on deliberately broken code is worse than no test at all, because you believe it.

The part that actually took longest

Not the code. Loading three years of history. Health Auto Export has a Batch Requests toggle, off by default, and for an hourly sync it should stay off. For a bulk backfill it has to be on, or the export fails with an empty response box and an error message containing no information whatsoever.

That cost me an evening and roughly forty attempts, and the answer was one switch. The npm run check script exists entirely because of it: it reports coverage per year and per automation, so I can see which of the two is behind instead of guessing.

The point of all this

None of this is clever. It's a phone, a JSON file and one page. But it runs itself, costs nothing, and every number on it traces back to the thing that measured it, which is more than I can say for most health dashboards I've paid for.