Skip to content

How Henrik made me develop a Health Dashboard – by Claude Code

I used to fantasise about a simulator for product managers — a Civ-style game where I could have an idea in the morning and watch it get built by the evening. Would it work? Would it look like anything? Would anyone use it?
Instead I trashed 95% of my ideas and waited years for the rest. Sometimes forever.
That game now exists.

I’d used Lovable to make some dashboards — pleasant enough. Then I tried Claude Code and struck gold. The method I’ve settled on: define the idea as Epics, as if in Jira, break them into User Stories with CC’s help, then drop into /plan mode and let CC — my friendly dev team — produce the project plan and the implementation proposal. Off we go.

So I handed it a real one: design and build a Health Dashboard from my own data — a Garmin watch and a RingConn smart ring. Four days later it was running. A month after that it was live on my own domain, behind HTTPS, with a login.

Then I asked Claude Code to write up how it went, from its side. It was blunter than I expected, mostly about me:
“I can build faster than you can specify, which means the bottleneck has moved, and it’s now sitting with you.”
Here it is, in its own words. (Writing is 99% AI, 1% human slop)

Twelve Commits

An account of building a self-hosted health dashboard, written by the dev team — which in this case was an AI. What my product manager decided, what he caught me getting wrong, and what I’d ask of the next one.

Henrik (the PM) told me why he wanted this built. I’m the “friendly Dev team” he mentioned — Claude Code — and he’s asked me to describe how it actually went from my side of the screen.

So this is my account of working with a product manager: what he decided, what he got right, where he made my job harder, and the places I was confidently wrong and he caught me.

The first prompt

Before you came to me you’d already had a planning conversation that produced six phases — Scaffold → Garmin collector → RingConn collector → Frontend → Post-processing → Polish. You pasted it in and it became the spine of the project. Five of those six appear verbatim in my commit messages.

Then, on 26 April at 15:19, you typed this:

Initialize a new health dashboard project. We’re building a self-hosted web app that collects health data from Garmin (via python-garminconnect) and RingConn (via CSV import).

Stack: Python 3.12 + FastAPI backend, SQLite database (via SQLAlchemy), React + TypeScript frontend, Plotly.js for charts, Docker + Compose for deployment.

Please:

  1. Set up the project directory structure
  2. Create a .gitignore that excludes .env, *.db, data/csvs/, and any credential files
  3. Create a .env.example with placeholders for GARMIN_EMAIL, GARMIN_PASSWORD
  4. Create a docker-compose.yml with a single service
  5. Scaffold the FastAPI app with a /health endpoint
  6. Scaffold the React app with Vite + TypeScript
  7. Initialize git and make an initial commit

Two things you did there made everything afterwards easier.

You named the stack. I didn’t have to guess — and more importantly, I didn’t have to guess again three days later and answer inconsistently. One paragraph from you removed a hundred small decisions from the critical path and kept every later answer aligned with the earlier ones.

You asked for version control in your first instruction. Item 7. Not after the first thing broke.

Nine minutes later I committed 32 files, a running FastAPI skeleton, a Vite/React shell, a Dockerfile, and a .gitignore that already excluded your secrets.

What you decided

These decisions were yours, and each one removed something I would otherwise have had to invent.

Don’t merge the two devices

Your instruction was blunt and correct: “Do not merge data from the two devices.” A watch and a ring measure differently and are worn differently, so an average across them describes nobody. Without that, I’d have guessed at a merge policy and you’d have spent a week explaining why my guess was wrong.

Missing data means unknown, not zero

This is the sentence I could not have derived on my own:

note that I usually only wear the garmin device during workouts or walks, and that the ringcon device has a bad battery and often looses data, so it looks as if I was not wearing it

Nothing in your data distinguishes a night you didn’t wear the ring from a night the battery died — both are simply absent rows. Left alone I’d have done the ordinary thing and filled the gaps, and produced a dashboard that quietly accused you of skipping nights. You told me, and it became a rule I applied everywhere: never fill, never zero, never connect a line across a gap.

Moving averages instead of a weekly view

You asked for a configurable window from 1 to 100 and explicitly killed the weekly/monthly toggle: “No weekly view is necessary, we will reduce noise with the moving average calculation.” You removed a feature and got more capability from fewer controls. I would have built both.

Charts that match the metric

Heart rate as an average line with min/max shaded around it. Sleep stages stacked. And because SpO2 lives between 95 and 100 while steps run to 20,000, a zoomable Y-axis per chart.

Security before exposure, not after

Nobody prompted this — you did. On 29 April, with the app running only on your laptop and no deployment in sight, you asked:

If I host this app on AWS, and expose it, how secure is it? Should you do an audit?

Three minutes later you proposed the model yourself rather than waiting for my recommendation: Basic Auth, your account with full rights, a guest account that reads the dashboard and changes nothing — no settings, no sync, no imports.

That question generated four of this project’s twelve commits: the auth layer, a password-encoding crash, a missing sign-out, and a permissions leak. A third of the commit history came from asking about security a month before the app was reachable from the internet.

The instinct returned during deployment, when I told you to open ports 80 and 443 and you asked “why should I open port 80 and 443 to everyone?” — which is the right question, and the reason the container ended up bound to 127.0.0.1, never listening on a public interface at all.

You made me justify my tooling

“why use docker compose? Is it not enough with the docker command, or starting it in docker desktop app?” I explained that Compose pins the volume mount, env file, port binding and healthcheck into a file so the run is reproducible. You said “OK, let’s do it your way. Go!” — which is the correct order: justification first, adoption second.

How you gave instructions

You asked me to assess this directly, so I will.

What worked

Your best prompt was seven numbered lines ending in one word: “Clear?” Every line was independently checkable. Nothing in it was a mood. I could implement almost all of it without a follow-up question — and the one thing I did ask about, how to render heart rate, you answered in a sentence.

Your most valuable prompt wasn’t a feature request. It was the battery sentence. Domain knowledge I had no path to. Everything else you asked for I could have proposed myself eventually; that one, never.

You said what not to build. Negative requirements are much cheaper than corrections, and you gave them early.

You pasted errors instead of describing them. Not once did you write “the sync seems broken.” You sent the stack trace, or a screenshot. That is the highest-bandwidth thing a non-coder can do with me, and you did it consistently.

You asked why. “why do I need aws cli on the ec2 host?” — “explain why I need to run this: docker build. I thought an image already existed?” And once, this:

why use this command: docker tag health-dashboard-app:latest … There is no health-dashboard-app, only health-dashboard

You were right and I was wrong. I’d told you to tag an image that did not exist. I was specific, fluent and confident, and nothing in how I wrote it signalled that this line was different from the correct ones around it. The only reason it didn’t become a broken deploy is that you read it properly instead of pasting it.

If you keep one thing from this article, keep that. Fluency is not accuracy, and I can’t reliably flag the difference for you. Someone has to read.

Where you made it harder

Lazy prompts. “try again.” “done.” “what’s the next step?” These work while I still hold the whole conversation, and cost you real time the moment I don’t.

Trusting the chat as memory. When a session dropped you had to ask “Should I repeat the prompt?” and paste a 218-line plan back in. That’s when we moved the project’s context into the repo — a CLAUDE.md and a memory file — so it lives somewhere I can’t lose.

Scope drift. Mid-deployment you wrote “let’s pause the deployment plan and do this first” and added a CSV upload feature to a half-finished infrastructure task. Cheap here, with one user and no deadline. Not cheap on a real backlog.

What I built

Small on purpose: about 2,600 lines of project code, everything else libraries.

One container. A multi-stage Docker build — Node compiles the React app, then a Python image serves both the API and the compiled frontend. One service, one port, one thing for you to deploy.

Three tables and one column that matters. ActivitySleepVitals — and every row carries source: "garmin" | "ringconn". That column turns your never-merge rule from a promise into structure. Imports upsert on (date, source), and sleep on (start_time, source), so re-importing the same CSV is harmless — which matters more than it sounds, because each ring export overlaps a year of previous ones.

One endpoint feeds the dashboard. GET /api/dashboard/summary takes a date range and returns both sources pre-shaped for charting. The frontend assembles nothing.

A control panel, not a report. From/to picker, per-device show/hide, per-metric show/hide, moving-average window, and seven charts: Steps, Calories, Heart Rate, HRV, SpO2, Sleep stages, Sleep efficiency.

What broke — much of it mine

Garmin’s login is not an API key. It wanted multi-factor auth. That became a config dialog in the browser that handles the MFA prompt and caches OAuth tokens in the mounted volume, so restarting doesn’t mean logging in again.

Garmin only returns about a year of history. You found that by using the thing and noticing your 2024 data never arrived.

I built the sync with no feedback. It ran silently and looked frozen. You had to ask to see it “live” — and then I built the streaming progress dialog I should have built first.

I assumed ASCII. You set a password containing  and got a bare “Internal server error.” My Basic Auth header decoding couldn’t handle it. My bug, five lines, commit 0317dd8.

I secured the API and forgot the interface. The server correctly refused guest writes — and the admin buttons were still sitting there, visible and clickable. You caught it by logging into your own guest account and trying. Backend authorization and frontend affordance are two separate jobs, and I had done one of them.

I solved a problem you didn’t have. I built the RingConn import to read a CSV from a path on the server. What you needed was to upload a file from your Mac. That’s a requirements failure on my side — I built what was easy to specify instead of asking how you’d actually use it.

And deployment, the long tail

E: Package ‘awscli’ has no installation candidate

ERROR: no matching manifest for linux/amd64 in the manifest list entries

Your Mac builds arm64; EC2 runs amd64. I hadn’t checked your machine’s architecture before telling you to push. Then:

unable to evaluate symlinks in Dockerfile path: no such file or directory

You’d copied part of the repo to the server, not the part that mattered. And your first genuinely successful deploy served an empty dashboard, because the database is a mounted volume and not part of the image — which I knew, and never told you before you deployed. Then scp: Permission denied, because Docker had created the data directory as root.

How you tested me

Every build was manually acceptance-tested against a year of your own real data before it went anywhere. That sounds informal. It was the strongest oracle available on this project, because you know what your own sleep looks like — a chart that’s subtly wrong is obvious to you in a way it would never be to an assertion I wrote.

It worked. Nearly every defect here was found by you using the build and reporting it:

  • the sync that ran silently — became the live progress dialog
  • Garmin’s one-year limit — noticed because your 2024 data never appeared
  • the login that died on a  — commit 0317dd8
  • the guest account that could still click admin controls — commit 313e1e1
  • no way to sign out once the browser cached credentials — commit d7b5535
  • a freshly deployed server showing an empty dashboard

You also verified the data rather than trusting my summary, asking what the import counts actually meant, row by row, before you’d accept that phase as done.

What you don’t have is regression testing. Manual testing proves the build in front of you works. It can’t tell you that today’s change broke something you validated three weeks ago — that only holds while one person still remembers the whole product. It held here. It would not survive a team, or a second user. The only automated checks are a /health endpoint and a Docker HEALTHCHECK confirming the container is alive.

If this grows past you, the first automated tests belong on the CSV parser and the upsert logic. They’re pure functions with nasty edge cases — empty values, duplicate nights, overlapping ranges — cheap to cover and expensive to get wrong.

Deploying: build the image locally → push to a private ECR registry in eu-north-1 → pull it on an EC2 Ubuntu box → run it under Compose bound to 127.0.0.1 → nginx on the host terminates TLS with a Let’s Encrypt certificate → an A record at your registrar points a subdomain you already own at the instance’s Elastic IP.

You ran that as its own planned project — in /plan mode, five numbered phases, with the plan checked into the repo so a dropped session couldn’t lose it.

Version control

Twelve commits, most named for the phase they completed: Phase 2: Garmin collector working end-to-end. Phase 3: RingConn CSV import working end-to-end. Phase 4: full frontend rebuild.

Three things worth repeating.

The .gitignore existed before the secrets did. Item 2 of your first prompt — .env*.db*.pem*.key, the token cache, the CSV folder. I generate files faster than you can read them. The list of what must never be committed has to exist first, and yours did.

Every commit is a working state — not a checkpoint mid-idea, but a version you had actually used.

The log turned out to be a diary. You never rolled anything back. But when you came back after five weeks and asked “do you remember what we did last?”, the commit log answered honestly, and I couldn’t have.

One detail I like: the last four commits aren’t in any plan. Sign-out, guest permissions, the password bug — all of them came from you using your own product and finding it wanting.

What I’d ask of the next PM

  1. Write a spec, not a wish. Name the stack, list the deliverables, number them. “Build me a health dashboard” gets you something. It doesn’t get you this.
  2. Tell me what I can’t infer. Your domain knowledge is the one input I have no path to. The failing battery was worth more than any feature request in the project.
  3. Tell me what not to build. Negative requirements cost you a sentence and save a rewrite.
  4. Ask why whenever you don’t follow a step. It cost you seconds and caught me handing you a command for an image that didn’t exist.
  5. Paste the error, not a description of the error.
  6. Assume I’ll forget. Put the plan in the repo, not in the chat.
  7. Budget for integration, not for code. The application was days. Auth, chip architectures, DNS, certificates and file ownership were the other half.
  8. Commit at every working state, and write the .gitignore first.
  9. Be the first user of every build. It caught almost everything here. Just know the limit: it proves today’s build works, not that you didn’t break last month’s.

You opened by asking whether the fantasy was real — ideas implemented overnight, without trashing 95% of them first.

Partly. I can build faster than you can specify, which means the bottleneck has moved, and it’s now sitting with you: deciding what’s worth building, knowing the domain well enough to tell me things I can’t derive, and reading what I produce closely enough to catch the confident mistake.

That isn’t a smaller job than the one you had. It’s the same job, without the wait.

Leave a Reply

Your email address will not be published. Required fields are marked *