Skip to main content
Glama
README.md
# PoLR Workout — an MCP App for Claude

Log and review workouts from inside Claude, on desktop, web or mobile. Say
*"logged a 45 min Power Zone ride, 212 average watts"* and get a saved workout with an
editable card in the conversation. Say *"starting leg day"* and get a live session card
with a rest timer that you tap through between sets while Claude plans the next one.

Built on the [MCP Apps extension](https://github.com/modelcontextprotocol/ext-apps)
(`io.modelcontextprotocol/ui`, SEP-1865): a tool declares a `ui://` resource, and the
host renders that HTML in a sandboxed iframe inline in the chat.

It's a real app someone uses — it's also written to be read. Most of what's interesting
about MCP Apps isn't the protocol, it's the decisions underneath: which tools get a view
and which deliberately don't, how a card and a conversation stay in agreement about what
just happened, what to do when the card scrolls out of sight. Those are all documented
below and in the code.

## What's worth stealing

| Pattern | Where |
|---|---|
| Tools the model can't see, only the view can (`visibility: ["app"]`) — so browsing and filtering never fill the conversation with rows | [src/server/server.ts](src/server/server.ts) |
| A write tool that renders **no** view, so repeated calls don't stack a card per call | `log_set`, `suggest_next` in [src/server/server.ts](src/server/server.ts) |
| Pushing what's on screen back to the model with `updateModelContext`, so the user's edits aren't invisible to Claude | [src/session/model-context.ts](src/views/session/model-context.ts) |
| Declaring `outputSchema` and `annotations` on data tools — and why putting them on a **view-rendering** tool can stop the app appearing at all | [src/output-schema.ts](src/server/output-schema.ts), `NO_SCHEMA_ON_UI_TOOLS` in [src/server/server.ts](src/server/server.ts) |
| Asking for `pip` to survive a growing conversation — and not escalating when refused | [src/session/use-display-mode.ts](src/views/session/use-display-mode.ts) |
| A timer anchored to the server so the view can remount, or open on a second device, and still be right | [src/session-app.tsx](src/views/session-app.tsx) |
| Self-contained view bundles: inline SVG charts, no CDN, because the iframe CSP blocks external hosts | [src/viz.tsx](src/views/viz.tsx) |
| A single auth seam you can swap for OAuth without touching anything else — and a name-in-the-URL namespace scheme good enough to hand a prototype to friends | [src/auth.ts](src/server/auth.ts) |

## Quickstart

Needs Node ≥ 22.18 (the server runs TypeScript directly via Node's type stripping).

```bash
npm install
cp .env.example .env

# Generate a token and put it in .env as MCP_SHARED_TOKEN
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"

npm run build:views          # bundle the three views into dist/
node --env-file=.env main.ts # serve on :3001
```

Then, in another terminal:

```bash
npm test                              # unit tests
node --env-file=.env scripts/smoke.ts # drives every tool over the wire
```

Your endpoint is `http://localhost:3001/mcp/<token>/<your-name>` — see
[Adding it to Claude](#adding-it-to-claude) for what the two segments mean.

To actually see the views rendered, run the reference host from the ext-apps repo
(`examples/basic-host`, `npm start`) against this server with
`SERVERS='["http://localhost:3001/mcp/<token>/demo"]'`, then open <http://localhost:8080>.
`node --env-file=.env scripts/render-seed.mjs` fills that `demo` namespace with a live
session so there's something to look at.
That host connects from a browser, so start the server with `ALLOW_CORS=1` — **dev
only**; production never needs it, because Claude calls the server directly and the
views talk to it over the host's postMessage bridge rather than browser `fetch`.

## Adding it to Claude

The server has to be reachable from the public internet — Claude connects from
Anthropic's cloud, not from your machine. Put it behind a TLS-terminating reverse proxy
(Caddy, nginx, Traefik, a Cloudflare Tunnel) and add your domain to `ALLOWED_HOSTS`.

Then: Settings → Connectors → Add custom connector → paste the full URL, which has two
parts after `/mcp/`:

```
https://workouts.example.com/mcp/<token>/<name>
                                 └─┬──┘  └─┬─┘
                     gates the server      your first name — picks your namespace
```

So if the token is `9f3a…` you'd use `…/mcp/9f3a…/amit`, and someone you're sharing the
server with uses `…/mcp/9f3a…/dana`. Same server, same token, separate data. The name is
folded to lowercase with punctuation collapsed, so `Amit`, `amit` and `  AMIT  ` are all
the same namespace and you can't lock yourself out by capitalising it differently later.

Ask Claude *"which workout namespace am I in?"* to check — the `whoami` tool answers with
the namespace and how much is in it, which is the quickest way to tell a fresh install
from a typo'd name.

Works on every Claude surface. **The URL is the credential** — treat it like a password.
See [Security](#security-what-this-scheme-does-and-doesnt-do) below for what the two
segments do and don't protect.

`/mcp/<token>` with no name still works and resolves to `WORKOUTS_USER_ID` (default
`local`), so a single-user deployment that predates the name segment keeps running
untouched.

## Tools

Model-facing:

| Tool | UI | What it does |
|---|---|---|
| `log_workout` | log view | Saves a finished workout, then renders it as an editable card |
| `show_workouts` | dashboard | History, weekly volume, PRs, trends |
| `get_workout_stats` | — | Plain data, so Claude can reason about progress |
| `start_session` | session view | Opens a live session with a rest timer |
| `log_set` | **none, deliberately** | Appends one set to the running session — reps and load, or time and distance |
| `suggest_next` | **none, deliberately** | Puts Claude's plan on the screen as a tappable list, strength or cardio |
| `get_session` | — | Reads what's on screen now: the plan in the user's order, progress, next up |
| `end_session` | — | Closes the session and returns a full recap |
| `whoami` | — | Which namespace this connector is pointed at, and how much is in it |

App-only (`_meta.ui.visibility: ["app"]` — hidden from the model, called by the views):
`app_session_state`, `app_log_suggested_set`, `app_dismiss_suggestion`, `app_delete_set`,
`app_reorder_plan`, `app_start_from_last`, `app_rate_session`, `app_save_workout`,
`app_delete_workout`, `app_query_workouts`, `app_list_exercises`, `app_recent_templates`.
Browsing and editing go through these, so filter churn never lands in the conversation
context.

### Schemas and annotations, and the one place they must not go

Every tool declares an `inputSchema` ([src/schema.ts](src/server/schema.ts)). Most also declare
an `outputSchema` ([src/output-schema.ts](src/server/output-schema.ts)) and `annotations` saying
whether they read, write or destroy. Those earn their keep twice over: the model gets a
typed contract instead of inferring the shape from one example, and the SDK validates
every result before it leaves the server, so a field that quietly went `undefined` fails
loudly rather than three turns later as a confidently wrong answer.

**The three tools that render a view declare neither.** A tool carrying `outputSchema` is
announcing "my result is structured data", and at least some hosts take that at its word:
the result renders as text plus a `</>` JSON toggle and the `ui://` resource is never
mounted. The tool runs, the result is valid, `_meta.ui.resourceUri` is right, the resource
lists and reads fine — and no app appears.

That failure is silent from the server's side, which is what makes it expensive. It was
found by diffing the wire format of a broken app against two that render: same SDK
versions, identical `initialize` capabilities, identical `resources/list` shape, identical
result envelope. `outputSchema` and `annotations` were the only structural difference
across the whole protocol surface.

This is a correlation across apps on one host plus a precisely matching symptom, **not a
documented rule** — the spec doesn't say the two are incompatible, and it may be
host-specific or since fixed. It's kept because the trade is lopsided: the keys buy a
typed contract on three tools, and being wrong costs the entire reason the project exists.
[scripts/smoke.ts](scripts/smoke.ts) asserts the invariant both ways so a tidy-up can't
quietly reintroduce it.

The cost is losing SDK-side validation on those three, softened by the app-only tools
returning the same shapes *with* schemas — `app_save_workout` mirrors `log_workout`,
`app_query_workouts` mirrors `show_workouts`, `app_session_state` mirrors
`start_session` — so drift still fails loudly somewhere.

Two other things that catch people out:

- A declared `outputSchema` makes `structuredContent` **mandatory** on any non-error
  result. `isError: true` skips validation, but a success that returns only text does
  not. That's why `get_session` answers "no session is running" with the empty state
  rather than a bare line of text.
- The schemas are annotated with the domain types they mirror (`z.ZodType<PlanEntry>`),
  so drifting from [src/types.ts](src/types.ts) is a compile error rather than a runtime
  surprise. That check still covers the three UI tools, which is what keeps their
  payloads honest without a declared schema.

## Live sessions

`start_session` opens a card with a rest timer for logging set by set while training.

**`log_set` has no UI resource on purpose.** A tool that declares a view renders a fresh
card on every call, so eight sets would stack eight cards in the conversation. Instead
the one open session card polls `app_session_state` every 2s, which means both paths land
in the same place:

- **Dictate to Claude** — "225 for 5" → `log_set` → the open card picks it up. This is
  the reliable hands-free path: it works on every surface, needs no iframe permission,
  and leans on the host's own dictation.
- **Tap in the view** — steppers and a per-row "Log set" button, 48px targets for use
  with chalk on your hands.
- **In-app voice** — the session resource requests `permissions: { microphone: {} }` and
  uses the Web Speech API, parsing phrases locally via
  [src/parse-set.ts](src/views/parse-set.ts) ("225 for 5", "bench 185 by 8", "two twenty five
  for five"). This is **progressive enhancement only** — the API is absent in some
  engines (notably Electron builds without a speech backend), so the button is
  feature-detected and simply doesn't appear when it's unavailable. Nothing depends on it.

**Talking is the interface.** The card is built around the conversation, not a form:

- **One list.** The plan and the work are the same rows: each exercise shows its target,
  the sets you've logged against it, and whether it's done. Two lists meant a
  half-finished exercise appeared twice and you had to read both to know where you were.
  Rows keep a stable order — suggested exercises in Claude's order, then anything logged
  off-plan — so nothing moves out from under your thumb mid-set.
- **Off-plan work joins the list**, tagged "added". Do something Claude didn't suggest and
  it gets its own row with its sets, rather than being invisible.
- **Progress is derived from the sets, never counted separately.** A dictated set advances
  the plan exactly like a tap does. A stored counter only moved when you tapped, so a set
  told to Claude — the main way sets get logged — left a row reading "0 of 3" beside the
  very set that satisfied it. Warmups land on the row but don't count toward a target.
  A revision needs no special handling either: progress lives in the sets, which a
  revision doesn't touch.
- **Removing a suggestion** (the × on a planned row) drops it from the plan and keeps any
  sets already logged under that exercise — the row simply becomes off-plan work.
- **Drag the ⠿ handle to reorder.** Implemented with pointer events rather than HTML5
  drag-and-drop, which never fires on touch — and a phone propped against a rack is the
  main way this gets used. The handle sets `touch-action: none`, without which the browser
  claims the gesture for scrolling and no `pointermove` ever arrives. Arrow keys on the
  handle move a row too, so reordering isn't drag-only. Polling is suspended mid-drag:
  otherwise a refresh two seconds in replaces the order being dragged with the server's.
  The order persists in its own `plan_order` table keyed by exercise — not on
  `suggestions.position`, because off-plan rows have no suggestion and still need to be
  draggable. Rows you place stay put; anything arriving later (a new suggestion, or a set
  logged for something new) appends in arrival order, so nothing jumps above what's
  already on screen.
- **`suggest_next` carries no UI**, same reason as `log_set`: a plan that spawned its own
  card would stack a new one every time Claude changed its mind.
- **Log something else by hand** — the exercise field, steppers, voice button and rest
  target live behind a disclosure. They're the fallback, not the main event.

**Phone layout.** The plan row's four-column desktop grid doesn't degrade at phone width, it
explodes: with no room each column wraps onto its own line, so the handle floats centred
above the name, the button goes full-bleed and the remove × ends up orphaned beneath it.
Below 520px the cells are placed explicitly instead — handle left, name and × on the first
line, target and set chips beneath, action button bottom-right — with **fixed** outer track
widths and `width: 100%` on the row. `auto` tracks collapsed to 0px and, because the row was
sizing to its content rather than filling the card, `1fr` behaved like max-content and every
row resolved different columns, stepping the names raggedly rightward.

Note the class named `.manualEntry`: the manual-entry form and the plan row both used to be
called `.entry`, so an old `@media (max-width: 460px)` rule written for the form silently
rewrote the plan row's grid to a single column. Two components must not share a class name.

**Opening and closing.** Both ends of a session used to be blank. Now:

- **Opening** — `start_session` looks up the last comparable session (same title, falling back
  to same type) and returns its exercises with top sets. The card shows them with a one-tap
  "Start from last time" (`app_start_from_last` copies them into the plan), and the tool text
  tells Claude to call `suggest_next` with a concrete plan rather than asking what you're
  doing. An empty card wastes the moment.
- **Closing** — `end_session` returns a full recap: per-exercise numbers plus **records broken,
  judged against everything logged before this session** (so a session can't beat itself). The
  card shows a completion screen — volume as the hero figure, tiles, any PRs, per-exercise
  breakdown, and an RPE/notes capture via `app_rate_session` — instead of the one grey line
  that made it read as "the app just closed". It also no longer drops out of `pip` on finish,
  which was making the card vanish at the exact moment it had something to say.

An app can't make Claude speak — `sendMessage` only stages a draft in the composer — so the
card carries the closing experience itself, and the recap goes into model context so that
asking "how did that go?" gets an informed answer rather than a re-derived one.

**Keeping the model in step.** Nothing the user does in the card reaches Claude on its own —
dragging rows, removing an exercise, tapping a set. Left alone, Claude keeps answering
"what's next" from the plan it proposed and names the wrong exercise. Two things fix that:

- `get_session` lets Claude read the current plan, in the user's order, with progress and an
  explicit "NEXT" marker. Its description says plainly that the order from `suggest_next` is
  not authoritative afterwards.
- The view pushes a short summary through `updateModelContext` after every user-initiated
  change (debounced 600ms), so Claude is told even when it doesn't think to look. Polls
  deliberately don't trigger this — only actions the user took.

Every `updateModelContext` payload in all three views uses YAML frontmatter followed by prose,
including one-line events, so the model never has to guess whether a block is structured.

**Staying visible.** Every dictated set adds a turn, which pushes the card up the
conversation, and the protocol has no "scroll to me". So when a live session opens, the view
requests the `pip` display mode — a floating card that stays put — and switches to a compact
layout (timer, what's next, one tap to log it, Finish) sized for a small container. Whether
`pip` is granted is up to the host: if it isn't offered the card stays inline and shows a
one-tap "Go fullscreen" instead. It never escalates to fullscreen on its own — taking over
the conversation uninvited is worse than a card that scrolled.

In fullscreen the view drops its container radius (`.main.fullscreen { border-radius: 0 }`) so
content reaches the viewport edges instead of leaving rounded gutters. Both the dashboard and
the session view do this.

Treat the *granted* mode from `requestDisplayMode` as authoritative rather than the one you
asked for: a host may grant something different, and isn't obliged to follow up with a
host-context change.

The rest timer is anchored to the server: elapsed time is derived from the stored
`logged_at` of the last set, never from a client-side counter. So the view can remount, or
open on a second device, and still show the right number. Polling pauses via
`IntersectionObserver` when the card scrolls offscreen and stops when the session ends.

An in-progress session is excluded from history and stats until `end_session` closes it —
it has no duration yet and would skew every rollup.

## Cardio

A set is reps and load, or time and distance, or both — `durationSec` and `distanceM`
sit on every set alongside `reps` and `weightLb`, and all four are nullable. "Rowed 2k in
8:30" is `distanceM: 2000, durationSec: 510`; "10 minutes on the bike" is `durationSec:
600` on its own.

They went onto the existing `sets` table rather than a parallel cardio one because a set
is still a set: it has a position in the session, a `logged_at` that anchors the rest
timer, and an exercise. A second table would have doubled every read path for one
differing pair of columns.

**Distance is stored in metres, always.** Rowers and tracks are metric, the treadmill
here is not, and picking one storage unit means the pace maths never has to ask which.
The views convert on the way out — under 2 km reads as metres and `/500m`, above it as
miles and `/mi`, from [one threshold](src/views/format.ts) so a distance and its pace
can't disagree about scale. The model-facing text in
[src/server/server.ts](src/server/server.ts) deliberately does *not* share that rule: it
uses metres and min/km throughout, because Claude reads those strings to reason with and
one fixed unit beats an idiomatic one that changes shape with distance.

`exercises.modality` (`strength` | `cardio`) says how an exercise is normally measured,
which is what makes the session card offer a distance and a clock instead of a weight
field. It's a **default, not a constraint** — the columns are on every set, so a timed
plank or a weighted carry records whatever fits without being reclassified.

### What cardio does to the numbers

Volume is `reps × weight`, so a cardio set contributes nothing to it — correctly, but it
means a conditioning session has a real `distanceM` and a `volumeLb` of zero. Every
header treats that as a different *kind* of session rather than an empty one and shows
only the figures with something behind them; "0 lb" never appears.

Records split the same way. `ExercisePr` stays strength-only and a parallel `CardioPr`
carries farthest distance, longest time and best pace, so no existing reader had to start
handling nulls it can never see. Two things are easy to get wrong here and are worth
knowing about:

- **Pace runs backwards.** It's stored as seconds per km, so a record is a *smaller*
  number than the one it beat. `prBeats` in [src/types.ts](src/types.ts) is the only
  place that comparison lives, and `prAgainst` is the only place that decides between
  "up from" and "down from" — an earlier shape of this said "up from" for everything and
  reported a faster 2k as a regression.
- **A pace record needs a floor.** Without one a ten-metre burst sets an untouchable
  pace and every real effort after it looks like a decline, so only sets of at least
  400 m can claim one.

Dictation is unit-driven, never magnitude-driven: `parseSpokenSet` reads "500 metres" as
a distance and a bare "500" as a load. Guessing from the number would file a heavy single
as a rowing record, and [the test for it](src/views/parse-set.test.ts) is there to keep it
that way.

## Layout

The tree splits on the runtime boundary: `src/server` runs in Node, `src/views` runs in
the host's sandboxed iframe, and exactly one module is shared between them.

```
main.ts                     HTTP entry — /mcp/:token (stateless Streamable HTTP), /healthz

src/types.ts                The one shared module: domain types both sides import

src/server/                 Node only — never bundled into a view
  server.ts                   Tool + UI-resource registration
  auth.ts                     The auth seam — swap this for OAuth, nothing else changes
  db.ts                       SQLite connection + the schema, declared in one place
  queries.ts                  All reads and writes; rollups in JS for correct local-time weeks
  schema.ts                   Zod shapes for tool input, plus date normalization
  output-schema.ts            Zod shapes for tool output, checked against src/types.ts
  tz.ts                       The one place WORKOUTS_TZ is read

src/views/                  Browser only — bundled by Vite into dist/
  use-mcp-app.ts              The host-connection shell all three views share
  log-app.tsx                 Log/edit view
  dashboard-app.tsx           Dashboard view
  session-app.tsx             Live session view — state and server calls
  session/                    its screens and hooks:
    plan-list.tsx               the one merged list of plan + logged work
    compact-card.tsx            the pinned (pip) layout
    summary-screen.tsx          the closing screen
    opening-screen.tsx          "start from last time"
    rest-ring.tsx               the timer ring
    use-plan-drag.ts            pointer-event reordering
    use-display-mode.ts         pip/fullscreen negotiation
    use-speech-set.ts           Web Speech API lifecycle
    model-context.ts            what the card tells the model about itself
  parse-set.ts                Spoken phrase -> set; DOM-free so it's directly testable
  viz.tsx                     Inline-SVG chart primitives (no chart library — CSP)
  format.ts                   Display formatting for the views (viewer's locale)
  insets.ts                   Safe-area insets as CSS custom properties

scripts/smoke.ts            End-to-end: drives every tool over Streamable HTTP
scripts/render-seed.mjs     Seeds a live session so you can look at the view
```

**The split is enforced, not just suggested.** `npm run typecheck` runs two configs
rather than one: [tsconfig.server.json](tsconfig.server.json) drops the `DOM` and
`DOM.Iterable` libs, so a `document` or `localStorage` reference that drifts into server
code is a compile error instead of a crash at request time;
[tsconfig.views.json](tsconfig.views.json) keeps them. Together they cover every file, and
`src/types.ts` is checked by both — the one shared module has to compile with *and*
without the DOM to stay shared. The root [tsconfig.json](tsconfig.json) still spans
everything, for the editor and Vite.

The runtime image follows the same line: the Dockerfile ships `src/server` and
`src/types.ts` only, because the view sources exist to be bundled and are already baked
into `dist/`.

## Development

```bash
npm run typecheck   # tsc --noEmit
npm test            # unit tests: the spoken-set parser, date normalization
npm run smoke       # end-to-end against a running server
npm run build       # typecheck + bundle all three views
npm run dev         # watch the log view and restart the server on change
```

`npm test` covers the pure logic that's easy to get subtly wrong and hard to notice: the
spoken-set parser, timezone-aware date handling, and the name → namespace mapping.
`scripts/smoke.ts` covers everything else by driving the real server over the wire — 52
assertions about tool behaviour, including that two namespaces genuinely can't see each
other's workouts or exercises, and that no view-rendering tool has picked up the keys
that stop it rendering. Because every tool declares an `outputSchema`, running it
also validates every response shape.

It works in its own `smoke-test` namespace and deletes the workouts it creates, so it's
safe to point at a server with real data on it — set `MCP_ORIGIN` to run it against a
deployment, which is the only way to catch host-facing regressions that don't reproduce
locally. It does leave a few learned exercise rows behind in its own namespaces, since
there's no tool to unlearn one; they're invisible to every other namespace.

`.env` is gitignored; [.env.example](.env.example) documents every variable. Local dev
defaults to `./data/dev.db`; the container uses `/data/workouts.db` on a mounted volume.

## Deployment

```bash
cp .env.example .env    # fill in MCP_SHARED_TOKEN, ALLOWED_HOSTS, WORKOUTS_TZ
docker compose up -d --build
```

The container listens on 3001, bound to loopback. Point your reverse proxy at it and
put the public hostname in `ALLOWED_HOSTS` — the server binds `0.0.0.0` so the proxy can
reach it, and the Host allowlist is what stops it answering requests for someone else's
hostname (DNS-rebinding protection). The database lives on the `./data` volume.

Verify with the health endpoint, which needs no token:

```bash
curl -s https://workouts.example.com/healthz -w '\n%{http_code}\n'
```

## Security: what this scheme does, and doesn't, do

The endpoint is `/mcp/<token>/<name>` — a capability URL with a namespace on the end.
It's this way because Claude on mobile can't attach custom headers, so everything has to
live in the URL the connector is configured with.

The two segments do different jobs, and it's worth being blunt about both:

**`<token>` gates the server.** It's a shared secret, compared in constant time. A wrong
token gets the same bare `404` as a wrong path, so the endpoint isn't discoverable, and
the token is never written to the log — the access log prints `/mcp/<token>/amit`
literally, keeping the namespace (useful) and dropping the secret (not).

**`<name>` separates data, not people.** Every row is scoped by the namespace derived
from it, so two testers with the same token genuinely don't see each other's workouts —
not their history, not their live session, not even the exercise names they've invented.
But anyone holding the token can reach any namespace by typing a different name. There
is no per-person secret.

So: this is enough to let a handful of people try the thing without tripping over each
other's data. It is **not** enough to keep them out of each other's data if they go
looking, and it's not a substitute for real auth. Give the token to people you'd be
relaxed about, and don't log anything you'd mind one of them reading.

Also worth knowing:

- The subdomain has to be **public**, not behind an SSO proxy: Claude connects from
  Anthropic's cloud and can't complete an interactive login.
- Names are folded to a key (`Mary Jane` → `mary-jane`) built only from letters and
  digits. That key is a SQL parameter and never touches the filesystem, and the display
  name shown to the model is rebuilt from the key — so a namespace can't be named
  something that reads as an instruction.
- Namespaces are created implicitly. Typing a name nobody has used gets you an empty one,
  which is the intended way to onboard and also the most likely way to be confused; ask
  Claude to run `whoami` if a namespace looks emptier than expected.

Moving to real auth is a change in one file: replace `authenticate` in
[src/auth.ts](src/server/auth.ts) with OAuth token verification and derive `userId` from the JWT
`sub`, still returning the same `AuthContext`. Nothing downstream reads the token or the
raw name, and `user_id` is already on every row, so there's no data migration.

## Notes on the design

- **Stateless transport.** `sessionIdGenerator: undefined`, a fresh `McpServer` per
  request. The standalone GET stream answers `405` rather than opening an event stream
  that can never carry a message — left to the transport, a client waiting on it hangs
  instead of failing.
- **`user_id` on every row**, including the `exercises` dictionary. That was the last
  table without one, and it made exercise names the one thing that crossed between
  namespaces — someone else's invented lift turning up in your autocomplete. `NULL` now
  means "built-in, shared by everyone" and anything learned belongs to whoever learned
  it. Carrying `user_id` from day one is also what makes moving to OAuth a change in one
  file rather than a migration.
- **The schema is declared, not migrated.** [src/server/db.ts](src/server/db.ts) states
  the tables as they are now and creates them with `IF NOT EXISTS`, so opening an
  existing database is a no-op. Before 1.0, with the data on one box, that reads better
  than a replay of six diffs — you can answer "what columns does `sets` have" by looking.
  The cost is explicit and worth saying out loud: **changing a table here does not change
  a database that already has data in it.** A new column means recreating the file or
  writing the `ALTER` by hand, and the moment the schema has to move under data worth
  keeping, this is the thing to replace with a real migration runner. Seeding is the
  exception — the built-in exercises go in with `INSERT OR IGNORE` on every open, so
  adding one to the list does reach an existing database.
- **Rollups in JS, not SQL.** Week and day boundaries have to respect `WORKOUTS_TZ`;
  SQLite's date functions only know UTC and localtime-of-the-host. Everything the *model*
  is told about time is formatted in that zone too, so Claude never reports an evening
  session as 2am.
- **PRs are derived, never stored** — max weight and best Epley 1RM per exercise,
  computed from non-warmup sets.
- **Charts are hand-rolled inline SVG.** The iframe CSP blocks external hosts and each
  view ships as one self-contained HTML resource. Views are bundled through
  `preact/compat` to keep each bundle around 390 kB instead of 550 kB — that's download
  cost on every device, including phones.
- **Every chart plots one measure**, so each is a single hue with no legend, a hover
  tooltip, and a table view. Colors are the validated blue accent (`#2a78d6` light,
  `#3987e5` dark), which clears contrast against Claude's light and dark surfaces.

## Not included

Peloton or Strava auto-import. The `source` column on `workouts` leaves room for it.

Per-set heart rate, watts or calories. A cardio set records time and distance only —
`workouts` already carries `avgOutputW` and `calories` for the session as a whole, which
is the granularity a Peloton class reports at anyway.

## License

MIT — see [LICENSE](LICENSE).