Skip to content

Hybrid

A training app on the App Store. Type your session as a sentence, import it from Apple Health, and get a score that makes an hour of yoga and an hour of intervals comparable.

production2026 – presentExpo · React Native · TypeScript · Supabase · Postgres

Free · iOS 15.1 or later

Where the month's points came from, sport by sport

On the App Store, free, in Sports, rated 16+Rejected by Apple review on the first build, then approvedEvery score is a TRIMP, weighted by heart rate zone, not by timeTraining one sport only caps you: variety is worth up to +25%A session can be typed in plain language and parsed into typed JSON

The problem

Training with other people has an accounting problem nobody wants to admit. An hour of yoga and an hour of intervals are both an hour, so any app that ranks people by duration, by reps or by sessions completed rewards whoever picked the easiest sport. Put four friends who run, lift, cycle and play padel on the same leaderboard and it stops meaning anything.

The second problem is smaller and more annoying. Logging four sets of ten at eighty kilos, then three sets of twelve, then twenty minutes on the bike, takes longer through a form than the workout deserves. Most people give up on logging before they give up on training.

What it does

Build a session across sixteen sports, then invite friends directly or push it to a group. Everyone runs the same plan and the results land side by side.

Describe the session in a sentence instead of filling a form, and it comes back as a structured plan with exercises, sets, weights and blocks, ready to correct before saving.

Record on whatever watch you already own. Afterwards, import the session from Apple Health: the heart rate samples are sliced into five zones, each minute is weighted by the zone it was spent in, and the number that comes out is what the leaderboards rank. That is the whole point of the app in one sentence.

Spread that effort across running, lifting, team sports and mobility and the month's total gets a multiplier. Pile it all into one week and the app says so, because it also watches the ratio between your last seven days and your last twenty-eight.

Distribution
App Store, free, rated 16+
Requires
iOS or iPadOS 15.1, Mac on Apple Silicon, Apple Vision
Scoring
Edwards zone TRIMP, with a MET based fallback
Heart rate
Read from Apple Health, never written back
Backend
Supabase: Postgres, auth, 81 row-level policies

Architecture

  • Apple HealthWorkouts, heart rate

    Hybrid

  • HybridExpo, React Native

    parse-workout, Postgres

  • parse-workoutDeno edge function

    Language model

  • PostgresRow-level security
  • Language modelStructured output
visitorthird partystoragethe api key never reaches the phone
01

Expo and React Native client

One codebase behind the shipped app. Colours, spacing and radii come from a single token file, so a value cannot be typed by hand inside a screen and quietly drift.

02

Postgres behind row-level security

Eighty-one policies decide who reads what. A friend sees your score and your session, never your account, and the client is never trusted to restrict its own queries.

03

TRIMP+ scoring

Turns a stream of heart rate samples into one comparable number: time in each of five zones, weighted one to five, then a variety multiplier and a seven against twenty-eight day load ratio on top.

04

parse-workout edge function

A thin Deno proxy in front of the language model. It exists so the API key lives on a server instead of inside an application anyone can unzip.

05

Apple Health import

Reads workouts and heart rate samples, writes nothing back, and keys every import on the Apple workout identifier so importing twice does not create the session twice.

Built with

Client

Expo and React Native
The single codebase behind the build submitted to review
React Native Paper
Components, themed from the one token file that owns colour and spacing
Zustand and React Query
Zustand holds the running session, React Query holds everything the server owns
react-native-healthkit
Reads workouts and heart rate samples, with no write permission requested

Backend

Supabase Auth
Email, Sign in with Apple and Google, with tokens kept in secure device storage
Deno edge functions
Six of them: workout parsing, push notifications, load warnings, account deletion
pg_cron
Hourly sweep that closes sessions the app never got to close itself
Postgres realtime
A friend finishing a session moves your leaderboard without a pull to refresh

Scoring

Edwards zone TRIMP
Minutes in zones one to five, weighted one to five, summed
Tanaka estimate
Fills in maximum heart rate from age when the athlete never measured theirs
Compendium MET values
Scores a session that arrived with no heart rate, so a leaderboard has no holes
Acute to chronic load ratio
Last seven days against the last twenty-eight, to say when to back off
IMPORTZONESTRIMPBONUSLEADERBOARD

Every stage runs on hardware I own

Under the hood

01The score is an integral of heart rate, not a stopwatch

Ranking by duration rewards whoever picked the gentlest sport, and ranking by a perceived effort slider rewards the optimist. So a session is cut into its time in five heart rate zones, each minute counts for one to five points depending on the zone it was spent in, and the total is the score. Maximum heart rate comes from the athlete's profile when they know it, and from their age when they do not. The cost is that the number means nothing without heart rate, and plenty of logged sessions were never recorded on a watch. Those fall back to a metabolic estimate, a published MET value per sport times duration times body weight, calibrated so an hour of running lands roughly where the zone formula would have put it. An approximation, treated as one, because a plausible number beats a hole in a table people compare themselves against.

02Being good at one sport is capped, on purpose

A monthly total that only counts effort makes the correct strategy obvious: pick your strongest sport and repeat it. That is a fine way to get injured and a poor definition of an athlete. So the month's minutes are spread across five families, aerobic, power, anaerobic, team sport and mobility, and the multiplier comes from how evenly they are spread: the coefficient of variation across the five, inverted, capped at plus twenty-five percent, and worth nothing at all until at least two families have minutes in them. It is a value judgement written into a formula, which is the honest way to have one.

03Two scores are written per session, on purpose

An app on the App Store and the database behind it do not upgrade at the same moment. A phone still running last month's build talks to today's schema, and a fresh build can meet a database whose migration has not run yet. So the old score is computed and persisted unconditionally, and the new zone based score is written afterwards inside a try/catch that is allowed to fail. Every read then takes the new score if it exists and the old one otherwise. It looked like clutter while writing it. It is the reason the scoring rewrite shipped without having to coordinate a release with a migration.

04The parser is allowed to say it did not understand

Free text into structured data is exactly where a language model will invent rather than admit defeat. So the response is constrained by a JSON schema instead of being parsed out of prose, the temperature sits near zero, and the model returns a confidence value alongside the plan. Below the threshold nothing is saved: the app shows the clarifying question the model asked and waits. A silently wrong set of weights in someone's training history is worse than a prompt for one more detail.

Gallery

The month so far, and the variety bonus it earned
Where the month's points came from, sport by sport
Each sport with its points, its time and its average heart rate
Importing from Apple Health, with the already synced sessions filtered out

Try it

It is on the App Store. That is the demo.

There is no sandbox to build and no demo account to hand out. It is on the App Store, it is free, and it installs like any other app. Scoring works without a watch: describe a session, save it, and the metabolic fallback gives you a number.

Heart rate zones need a session recorded on a watch and imported from Apple Health.

What broke

Would change

Apple rejected the first build, and not for a bug

Build one was refused on two guidelines. There was no way to delete an account from inside the app, and the Sign in with Apple flow asked again for a name and an email that Apple had already handed over. Both were policy, both were knowable weeks earlier, and both cost a full review cycle to find out. Account deletion is not a feature you bolt on at the end either: it decides what happens to the groups and workouts a leaving user created, which is a schema question. The rule taken from it is to read the guidelines covering authentication and account lifecycle before writing the authentication screens.

Would change

Sessions that never ended locked people out of starting new ones

Force quit the app mid workout and the row stayed in progress forever. The next time that user pressed Start, a guard rejected them for already having an active session, and the client only logged the error to the console: the button simply did nothing, with no message. It took someone telling me in person to find it. The fix had to land at four levels, from resuming the existing session instead of refusing it, up to an hourly job that closes stale sessions for phones that never come back. The lesson is narrower than it looks: a guard that can refuse must own the way back out, and an error the user never sees is a bug that reaches you as silence.

Would do again

Row-level security instead of trusted client queries

Writing eighty-one policies on a solo project felt like ceremony, and the day an avatar upload started failing without a single error anywhere, it looked like proof of that. The cause was one missing check clause on one policy. But the same strictness is why a leaderboard that joins across users could ship without auditing every query for a leaked column. A database that refuses by default is worth more than the afternoon it costs.

Where it stops

There is no pipeline, and the review agents run when I remember

The repository has seven specialised review agents, for design tokens, accessibility, performance and security, and they earn their place. They are also invoked by hand. There is no continuous integration and no test suite, so nothing refuses a commit that breaks a contrast ratio or hardcodes a spacing value. Calling that enforcement would read better than the truth, which is that it is a checklist with good ergonomics.

Next projectVoxa