Android app (Kotlin + Jetpack Compose) that computes a Fitness Age from Health Connect data.
Package namespace: com.phischmi.wakaage — derived from the GitHub owner. Change
namespace/applicationId in app/build.gradle.kts and the package directories if you want a
different one.
App icon and branding are deliberately out of scope for this version.
| Module | Type | Contains |
|---|---|---|
:domain |
plain Kotlin/JVM | the entire fitness-age algorithm, reference tables, unit tests |
:app |
Android | Health Connect access, Room history, ViewModel, Compose UI |
:domain has no dependency on Android, Compose or Health Connect — only the Kotlin stdlib
and java.time. That is what makes the algorithm exhaustively testable on the JVM: ./gradlew :domain:test needs no emulator and no Android SDK.
:app ──depends on──▶ :domain
│ │
│ HealthDataRepository │ FitnessInput ──▶ FitnessAgeCalculator ──▶ FitnessAgeResult
│ (Health Connect ──▶ FitnessInput)
│
└─ FitnessAgeViewModel ──▶ FitnessAgeUiMapper ──▶ FitnessAgeUiState ──▶ Compose
Four steps, all in :domain:
-
Reduce. Each Health Connect data type collapses to one robust metric — median resting HR, median nightly RMSSD, MET-minutes per week, a 0–100 sleep index, body fat % or BMI, and an estimated VO2max. Implausible samples are filtered first.
-
Score. Every metric is compared against an age- and sex-normed reference curve (
NormCurve), producing a domain age: the age at which that value would be average. Two mappings exist, chosen per metric in the reference file:CURVE_INVERSION— used where the population mean falls steeply and monotonically with age (VO2max, RMSSD). Literally "at what age is your value the average?".Z_SCORE_OFFSET— used where the age gradient is too flat or non-monotonic to invert (resting HR, activity, sleep, body composition). The z-score against the same-age norm is converted into years via an explicityearsPerSd.
Every domain age is capped at
± maxDeviationYearsfrom the calendar age so one extreme (or spoofed) metric cannot run away with the result. -
Renormalise. Domains without enough data are dropped and the remaining weights are scaled proportionally, so the effective weights always sum to 1 regardless of how much data exists.
-
Combine. The weighted mean of the surviving domain ages is the fitness age. A confidence score reports the weighted share of all six domains that was actually backed by well-covered data (
Σ weightᵢ × coverageᵢ / Σ weight), so it reaches 1.0 only with all six domains at full window coverage.
Below 3 scored domains no number is shown at all — the UI switches to an onboarding view that reports how many domains are still needed.
| Domain | Metric | Weight | Mapping |
|---|---|---|---|
| Cardiorespiratory fitness | estimated VO2max (ml/kg/min) | 0.35 | curve inversion |
| Activity | MET-min/week | 0.20 | z-score offset |
| Resting heart rate | bpm | 0.15 | z-score offset |
| Heart rate variability | RMSSD (ms) | 0.10 | curve inversion |
| Sleep | composite index 0–100 | 0.10 | z-score offset |
| Body composition | body fat % (fallback BMI) | 0.10 | z-score offset |
CRF is derived: it needs either (BMI + activity) for the NASA/JSC non-exercise equation, or a resting heart rate for the HRmax/HRrest ratio. With both, the two estimates are blended.
All reference numbers live in one file:
domain/src/main/kotlin/com/phischmi/wakaage/domain/reference/FitnessReferenceData.kt
The structure is final; the values are provisional. They are rounded, literature-derived
placeholders chosen so the algorithm behaves sensibly end-to-end — not a verbatim
transcription of any published table, and they must not be presented to users as such. Each block
names the source it should be reconciled against ([S1]…[S12]).
To swap in the definitive tables, replace the NormAnchor lists. Nothing outside that file has to
change. FitnessReferenceDataTest guards the invariants that must survive the swap: weights sum
to 1, every domain has a weight/requirement/citation, inversion curves stay monotone, and feeding
a population mean back in returns the age it came from.
Citations shown in the UI live next to them in ScientificSource.kt.
Built: fitness-age calculation, the hero visualisation, the domain breakdown with per-domain detail sheets, and the missing-data view.
Deliberately not built: streaks, trend charts, notifications. The architecture does not
preclude them — every run (including the ones too sparse to display) is already written to a Room
database via ScoreHistoryRepository, and observeRuns() is there waiting. No migration will be
needed to add the trend view.
./gradlew :domain:test # algorithm tests, no Android SDK required
./gradlew test # all unit tests
./gradlew :app:assembleDebug # debug APKlocal.properties must point at an Android SDK for anything touching :app.
WakaAge is not a medical device. The fitness age is a comparison against reference values and does not replace clinical assessment. The app reads from Health Connect only — it never writes health data and sends nothing to a server.