Distance Goal
How Distance Goal is built
What the app is made of, where its data lives, how a shared link works, and how releases are made and tested. Distance Goal is not open source, but we are open about how it is built. Signing keys, credentials and security detail stay private.
File names on this page come from the app's private repository: they are there to orient you, not to link to. Why it is built this way is on the decisions page.
What it is made of
Distance Goal is one codebase for Android and iPhone. The only feature that differs between them is Health Connect auto-tracking, which exists only on Android.
- Expo SDK 57 on React Native 0.86, written in TypeScript in strict mode, with the React Compiler on.
- expo-router for navigation, with typed routes: each file in
src/appis a screen. - No server, no account, no network requests. The app's own code opens no connection. Library code that could — Firebase Messaging, which comes with
expo-notifications— is in the app and never initialised. Everything lives on the phone.
Where things live
src/app— screens, one file per route._layout.tsxwraps the stack;+native-intent.tsxroutes incoming links.src/components— shared components.ui/is the design system: buttons, cards, the progress ring, form inputs.src/lib— the app's logic, almost all of it pure TypeScript and unit-tested; four thin adapters touch the phone (below).types.tsis the data model.src/store— persistence and state: loads and saves the app's data through AsyncStorage, and hands it to screens through React context.plugins— Expo config plugins, all of them Android-only.relay— the retired buddy-group server, a package of its own. Not part of the app.
Everything lives on the phone
Everything a person enters is one document, AppData, stored on the phone with AsyncStorage. There is no cloud copy and no sync.
- One versioned document. It was at schema version 14 in September 2026.
src/lib/storage.tsholds the whole migration chain from version 1, tested end to end. Each new version adds the same pieces: an interface, a type guard, a migrate function, a step up from the version before, a case in the switch, and both version constants intypes.ts. - Kilometres underneath. Every stored distance is in kilometres; miles are only a display setting.
- Calendar days as strings. A date that means a calendar day — a goal's window, an entry's date — is a local
YYYY-MM-DDstring, never a timestamp, so a walk logged on 12 July stays on 12 July in every timezone. - Corrupt data is backed up before it is salvaged, never thrown away.
- Nothing is dropped when a feature goes. The buddy-group fields are still in the model, so phones that had groups lose nothing.
- Off the cloud, on to the next phone. On Android, cloud backup is switched off for the app's data, and phone-to-phone transfer is deliberately left on: with no account, it is the only automatic way to keep everything when changing to a new Android phone. Why transfer stays on.
Pure logic, tested in plain Node
Every decision the app makes — what counts toward a goal, streaks, badges, journeys, the share card, the link format, which reminders to schedule, migrations, export, restore — is a pure function in src/lib, tested by Vitest in plain Node, with no device and no simulated browser. Screens and stores sit on top. The whole suite runs in about a second.
Four modules in src/lib are the exceptions, because they are where the app touches the phone:
storage.ts— AsyncStorage.export-share.ts— the file system, the share sheet and the document picker.reminders.ts—expo-notifications.health-connect.ts— Health Connect.
They are kept thin: the decision goes in a pure function beside each one, and the adapter just carries it out. The storage tests are the only ones that mock anything (AsyncStorage).
That is a testability rule and a portability rule at once: health-connect.ts imports a native module that throws on load on iOS, and it crashed the iPhone app at startup until it was put behind a platform file (below).
The native pieces
Each native module is there for one job.
The table scrolls sideways.
| Module | What it is for | Worth knowing |
|---|---|---|
react-native-health-connect | Auto-tracking, on Android | Reached only through src/lib/health.android.ts |
expo-notifications | Local reminders | Never asks for a push token. It statically links Firebase Messaging — never initialised, which is not the same as not there — and ShortcutBadger. Between them they bring the INTERNET, ACCESS_NETWORK_STATE, WAKE_LOCK and c2dm.RECEIVE permissions, and about twenty launcher-badge permissions |
react-native-share | Sending the card and the message together | Its photo-library references are why the iPhone app carries two photo-library purpose strings |
react-native-svg | The progress ring and the share card | The card is SVG, exported as an image with toDataURL. The image comes out at the card's on-screen pixel size, so it is smaller on a lower-density phone (984 × 984 on a 3× screen) |
expo-file-system, expo-sharing, expo-document-picker | Export and restore | Add no permissions (checked against 2.6.1's manifest) |
expo-dev-client | Development builds | Why Expo Go cannot run this app: it needs native modules Expo Go does not include |
Config plugins. Four Expo config plugins shape the Android build. All four are Android-only by construction, so none can break an iPhone build.
with-health-connect— the two health permissions, and the permissions-rationale intent filters Health Connect requires.with-data-extraction-rules— keeps the app's data out of cloud backup while allowing phone-to-phone transfer.with-r8-optimization— turns on R8's optimiser, which the generated project's default ProGuard file switches off with-dontoptimize.with-release-signing— lets a release built locally be signed. The key itself is kept outside the repository.
The android and ios folders are generated, not kept in the repository. A hand edit there lasts until the next prebuild, so anything that must stick goes in a plugin.
Health Connect on Android, nothing on iPhone
Auto-tracking reads distance from Health Connect, Android's store of health data. It is off by default and needs two opt-ins: a setting for the whole app, and a switch on each goal.
What is read
- Two read permissions, no more: distance and, from 1.6, exercise. The app never writes to Health Connect.
- From a workout, four things: its start time, end time, activity type, and which app recorded it — used only to tell a walk from a ride, because distance records carry no activity type.
- Never the route. The exercise-routes permission is not declared, so Health Connect cannot return coordinates. Never steps, heart rate, calories or sleep.
- Records are read again, not stored. Each time the app comes to the foreground it reads again. What it keeps is what the person's own choices need: one figure per day (so a badge can never be un-earned), the activity labels they set by hand, the sources they exclude, and — for a record they mark as a duplicate of a walk they logged by hand — that record's start, end, distance and source, so it stays uncounted.
- A total blended from Health Connect is still Health Connect data. Google's terms cover data derived from or aggregated with Health Connect as well as the records, with no exception for blending. So a goal total that includes auto-tracked distance leaves the phone only when the person sees it and sends it themselves — a card, a link, an export — and never for advertising, data brokers, or insurance, credit or employment decisions.
- What is read is a published claim. The fields taken from each record are listed in one place in
health-connect.ts. They include which app recorded it (metadata.dataOrigin), which is shown to the user and makes excluding a source possible. Add a field there and the privacy policy and the Health Connect declaration are wrong the same day.
The privacy policy sets out every field in plain words: Privacy Policy.
One interface, one Android implementation
The table scrolls sideways.
| File | What it is |
|---|---|
src/lib/health-provider.ts | The HealthProvider contract, pure helpers, and UNAVAILABLE_HEALTH. Imports nothing native. |
src/lib/health.ts | The resolver. Exports healthProvider, which is UNAVAILABLE_HEALTH on every platform without a file of its own. |
src/lib/health.android.ts | The only path to react-native-health-connect. |
src/lib/health-connect.ts | The Health Connect implementation of the contract. |
Everything imports healthProvider from @/lib/health, never the Health Connect implementation directly. Which file is used is decided by its name, per platform, so breaking the rule is a bundler error rather than a comment someone has to remember.
Why it matters. react-native-health-connect calls TurboModuleRegistry.getEnforcing as soon as it loads and ships no iOS code, so on iPhone it threw the moment it was imported — before any platform check could run. After any change to this boundary, both bundles are checked: the Android bundle must still contain the Health Connect calls (aggregateRecord, getSdkStatus) and the iPhone bundle must not. An Android bundle that has quietly lost the library is the worse failure, because it looks like nothing.
Two promises the contract makes that the type system cannot check:
- Totals are exact; the record scan may be capped. Goal totals come from Health Connect's own aggregate. The record-by-record scan behind the diary, History and the per-source breakdowns stops after a fixed number of pages, so anything built from it can under-report on a very long goal with a chatty tracker. That is a safety limit, not a bug.
- Fail closed, quietly. Nothing throws: a failure resolves to unavailable, false, zero or empty.
Smaller pieces: health-typing.ts maps Health Connect's exercise types to the app's activities (a development-time check in health-connect.ts guards that table against the library drifting); health-duplicates.ts handles a hand-logged walk that a gym app also recorded; and source-overlap.ts spots two apps recording the same walks (from 2.6.2).
On iPhone
The iPhone app reads no health data at all. iOS resolves to UNAVAILABLE_HEALTH, and the Health Connect library never reaches the iPhone bundle.
Adding Apple Health would mean writing src/lib/health.ios.ts exporting a HealthProvider, and nothing else would need to know. What would have to go with it:
- Empty source lists on iPhone. The stored lists of excluded and shared sources hold Android package ids written by real installs. They can never match an Apple Health source; reusing them is at best inert and at worst a future collision.
- A new activity table. HealthKit's workout types are a different set of numbers with different meanings, and need their own drift check.
- A decision on wording that is specific to Health Connect, such as asking someone to update the Health Connect app — a decision, not a rename.
- Documents first. HealthKit purpose strings, the Health section of Apple's App Privacy label, the privacy policy's “On iPhone the app reads no health data”, and the iPhone listing all change in the same release. The rule that documents change first.
The link format
A shared progress message ends in a link. Whoever taps it gets the identical goal on their own phone — same target, window, activities and, for a journey, the same route — and sees how far the sender had got.
This is a contract, not an implementation detail. Every link ever sent sits in somebody's chat history and must keep working in every later version of the app and of this website. The format changes only by adding a new version, never by changing what version 1 means.
The link
https://distancegoal.bluetorch.co.uk/join#v=1&ch=hadrians-wall&t=135&s=2026-09-06&e=2026-12-01&a=walking,hiking&k=42.1&p=0.312&d=2026-09-06- Everything rides in the fragment — the part after the
#. Browsers never send a fragment to a server, so the website sees that the page was opened and nothing else. The page's own JavaScript reads the fragment to show the goal. - No free text, ever. Numbers, dates and ids only. The goal's name is deliberately not in the link: a built-in journey supplies its own name, and a custom goal is named by the recipient's app from its figures (“50 km by 31 Jul 2026”). This is what keeps the app from ever showing one person's words on another person's screen — the fact every compliance answer since 2.6 rests on. There is no name field, and there will not be one.
- No identity. Nothing in the link says who sent it. The recipient knows, because it came to them in a chat from that person.
The keys
What the app writes, and what the app's reader (parseChallengeLink) accepts:
The table scrolls sideways.
| Key | Meaning | Written as | Accepted when |
|---|---|---|---|
v | Link version | 1 | Exactly 1 |
ch | Built-in journey id | Only for a journey | It matches an id in CHALLENGES (src/lib/challenges.ts) |
t | Target, in km | Up to 2 decimal places | More than 0 and at most MAX_TARGET_KM (100,000) |
s, e | Goal window, as local YYYY-MM-DD dates | Always | Both are real dates, and e is on or after s |
a | Activity ids, comma-separated | Only when the goal counts some activities, not all | Every id is an ActivityType; empty items and duplicates are ignored; absent means every activity |
k | The sender's distance so far, in km | 2 decimal places; always, from 2.6.1 | A finite number, 0 or more — and only together with p |
p | The sender's fraction complete | 3 decimal places | From 0 to 1 — and only together with k |
d | The date of the sender's snapshot | Always, from 2.6.1 | A real date; read only alongside k and p |
- The decimal places are how the app writes a link; its reader does not insist on them.
- Progress travels as a pair. 2.6.0 could leave out
k,pandd, so a reader never assumes them. A link carryingkwithoutp, orpwithoutk, is rejected. - A link that breaks any rule is rejected whole — never partly honoured.
- The app decides. This website has its own reader, which shows the goal on the join page. Every link the app writes passes both; for anything else — a hand-made or damaged link — the app's reader is the one that decides whether a goal can be created.
Hosts
The table scrolls sideways.
| Host and path | Written by | Accepted |
|---|---|---|
https://distancegoal.bluetorch.co.uk/join | 2.6.1 onward | Always |
https://www.bluetorch.co.uk/distance-goal/join | 2.6.0 only | Always |
The parser never looks at the host, so both work in every build from 2.6.0.
Old buddy-group invites
A fragment beginning DG1. is a buddy-group invite from before 2.6. The app and the website both recognise it and say plainly that buddy groups are no longer part of Distance Goal, and what to do instead. It is never treated as an error, and never honoured.
Adding a version 2
v=2 links would be written only by a build whose parser still reads v=1, with the website's parser kept in step. A version 1 reader rejects a version 2 link whole (the v rule above), which is the safe failure: it says “not a Distance Goal link” rather than creating the wrong goal.
What the app does with a link
- Routing.
src/app/+native-intent.tsxsees every incoming address before the router does. If it carries a goal, it is rewritten to an internal route (/join?c=…) that never leaves the phone./joinon the website is a web page and must not become an app route. Anything unrecognised falls through untouched, because an unmatched route on a cold start is a blank screen with no way back. - Fragment, not query. From 2.6.2 the handler finds the goal in the fragment or in a
?c=query. Builds up to 2.6.1 read only the fragment, and passed a?c=address through whole — which the join screen then rejected. So anything that opens the app with a goal, this website's Open in Distance Goal button included, sends the fragment form: the phones that need that button most are the ones without the fix. - The join screen (titled “Join a challenge”) shows the goal and, when present, the sender's snapshot (“Whoever sent this was at 42 km — 31% — on 6 Sep”). Start this goal creates it, then says “You're in” and goes to the dashboard. When the window has already ended, Start it from today offers the same length, starting today. When an identical goal already exists, it says “You're already on this one”, with both percentages and Share your card back.
- Nothing to read? An old invite gets its explanation. With no link at all, the screen offers the clipboard or a paste box, which accepts a whole message and finds the link inside it.
- Receiving a link only ever adds a goal. It never changes or deletes anything already on the phone, and nothing goes back to the sender.
- Where it lives. In
src/lib/challenge-link.ts:buildChallengeLinkandparseChallengeLinkwrite and read links;challengeLinkNamegives a custom goal its generated name;describeSnapshotwrites the “Whoever sent this was at…” sentence; andshiftedWindowmoves an ended window to start today. All pure, and tested inchallenge-link.test.ts. The routing is tested innative-intent.test.ts, and the share message is built byshareCaption().
How a tap reaches the app
- A custom scheme,
distancegoal://. The development build usesdistancegoaldev://, so the two installed apps never compete for it. - Android App Links. The app claims both hosts and paths (
android.intentFiltersinapp.json, withautoVerifyon). This website serves/.well-known/assetlinks.jsonon both hosts, naming the app's signing certificates — with a 200, as JSON, and no redirect. - An App Link fires only for a host the installed build claims. Android 2.6.0 (build 13) claims
www.bluetorch.co.ukalone, so a 2.6.1 link tapped on a phone still running it opens the browser, not the app. That resolves as phones update; until then the join page offers Open in Distance Goal. - iOS Universal Links. The app lists
applinks:for both hosts (ios.associatedDomainsinapp.json); this website servesapple-app-site-associationon both, listing both paths, with the right content type and no redirect.
What the website sees
- The address.
distancegoal.bluetorch.co.ukis attached to the same Vercel project as this website./joinon it is served directly — a rewrite, so the address a person tapped is the address the app matches. The/.well-knownfiles and the page's own assets pass through untouched, and every other address redirects permanently (308) to the matching page underwww.bluetorch.co.uk/distance-goal. - Without the app, the join page explains the link, shows the goal, and offers the store links and a copy button for pasting into the app later.
- With the app, when the tap opened the browser anyway: under “Already have Distance Goal?”, Open in Distance Goal hands the goal to the app. On iPhone that is
distancegoal://join#<fragment>; on Android, anintent://link (below) that falls back to the Google Play listing. Both depend on the scheme staying registered and on the app reading the fragment. - The chat preview has its own title, description, site name and a generated image in the app's teal: “Do this with me on Distance Goal”. The fragment never reaches the server, so a preview cannot name the goal.
- Measurement. A page view, and a cookieless, aggregate Vercel Web Analytics event recording the journey id and whether progress was included — never the figures. The privacy policy says so.
intent://join#<fragment>#Intent;scheme=distancegoal;package=uk.co.bluetorch.distancegoal;S.browser_fallback_url=<Google Play listing>;endHow releases are made
- Builds are made with EAS, Expo's build service. There is no Mac in the loop, so every iPhone-native mistake — a bad plugin, a bad
Info.plistkey, a missing entitlement — shows up only on a cloud build. That loop takes minutes, not seconds, so iPhone configuration changes are batched, and the one local check is resolving the app's configuration, with and without the development variant. - An Android release can also be built locally, signed with a key that is kept outside the repository.
- Version numbers are bumped by hand. The version name, the Android
versionCodeand the iOS build number live inapp.json(eas.jsonsetsappVersionSourcetolocal), so every change shows up in a diff. - No number means two things. Two different builds never share a version name, because Settings shows only the name. A build number is never reused once a signed build exists — even one no store ever saw — because a real signed file on disk can be uploaded by mistake. Why numbers are never reused.
- Every build is handed over with its store release name, its store notes and a changelog entry. The changelog lists every build, including the ones nobody saw, and it is the one place that says which build is live: What has changed in each version.
- Development builds install beside the store app, never over it. A development build has its own app id (
uk.co.bluetorch.distancegoal.dev), its own name (“Distance Goal (dev)”), its own link scheme and an amber icon. The variant is applied when the native project is generated, so the project is regenerated explicitly and its app id checked before anything reaches a phone. On 8 August 2026 only Android's signature check stopped a debug build that had picked up the store app's id from overwriting real data.
How it is tested
- Every change: the unit tests (
npm test— Vitest,src/libonly, about a second), the TypeScript compiler (npx tsc --noEmit) and lint. The retired relay has its own package, configuration and tests, and is left out of all three. - End to end means the last screen. A flow that crosses into another app — a share, a link, a notification — is proven by following it on a real device, on the bundle that actually ships, to the final screen. “The app opened” is not a pass. The fragment-versus-query problem in the link format was found only that way, by following a real link on a device running 2.6.0 as it shipped.
- Outward-facing work is verified by observing it. Fetch the address. Read the manifest. Check the timestamp. A claim repeated in six documents is not six pieces of evidence.
Testing a link
Routing is tested on the development build by firing the link at it directly:
adb shell am start -a android.intent.action.VIEW \
-d "https://distancegoal.bluetorch.co.uk/join#v=1&ch=hadrians-wall&t=135&s=2026-09-06&e=2026-12-01" \
uk.co.bluetorch.distancegoal.devLink verification can only be tested on a build installed from Google Play, because Android verifies links when an app is installed, not when it is reopened. This should report both hosts as verified:
adb shell pm get-app-links uk.co.bluetorch.distancegoalThe development build is deliberately left out of assetlinks.json. Both apps sit on the same phone, and two apps claiming the same links would break the very thing being tested.
The device script, for any release that touches sharing or joining — two phones, one of which once had a buddy group:
- 1Share my progress from phone A into a chat: the picture and the message arrive together. Tap the message on phone B: the join screen shows the goal and A's snapshot, and Start this goal creates it, then says “You're in”.
- 2A goal whose window has ended: B is offered Start it from today, and the new goal has the same length.
- 3The identical goal already on B: “You're already on this one”, both percentages, and Share your card back.
- 4An old
DG1.invite, tapped or pasted: explained, not an error. - 5The phone that had a group, after updating: no Buddies tab anywhere, and every goal, entry and badge still there.
- 6All of it in airplane mode. None of it needs a connection; that is the claim, and it takes a minute to check.
Lessons already paid for
- A spinner must be ended by the thing that started it. Binding a pull-to-refresh spinner to a global flag left it running for ever on iPhone: one that is already refreshing as it appears does not reliably end, and a flag cleared only on success stays set after an error.
- A display decision made inside a rate-limited background pass is not applied on tap. What the screen shows is filtered when it is drawn, from current state; the background pass only reads and writes.
- Comments explain the decision, not the code — especially why the obvious alternative is wrong. Several have stopped a bug being reintroduced.
- Say what is not done. Residual risks, caps, things that have never run on a device: written down. This project's failure mode is a stale claim, not a broken build.
More about Distance Goal
Distance Goal is published by Bluetorch Consulting Ltd.