P247 Body Composition — Phase 1 Implementation Brief
P247 Body Composition — Phase 1 Implementation Brief
Version: 1.0
Date: 26 March 2026
Author: James (for Myles Bruggeling)
Status: Ready for development
Parent spec: knowledge/projects/p247-body-composition-spec.md
Companion docs: P247 Backend API Development Brief (v1.2), P247 iOS App Development Brief (v1.2)
1. What Phase 1 Delivers
- HealthKit body composition data (weight, body fat %, lean body mass, BMI, resting energy) auto-syncs from Hume smart scale via the existing HealthKit pipeline
- Manual entry forms for InBody and DEXA scans
- Rolling trend chart (7d / 30d / 90d) with InBody/DEXA overlay pins
- Body comp card showing headline numbers from the highest-accuracy scan available
- All data stored in a unified
body_composition_scanstable
Phase 2 (OCR import) and Phase 3 (segmental analysis, normalisation) come later.
2. Backend API
2.1 Database: body_composition_scans Table
CREATE TABLE body_composition_scans (
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
user_id TEXT NOT NULL REFERENCES users(id),
source TEXT NOT NULL CHECK (source IN ('dexa', 'inbody', 'smart_scale')),
accuracy_tier INTEGER NOT NULL CHECK (accuracy_tier IN (1, 2, 3)),
measured_at TEXT NOT NULL, -- ISO 8601 UTC
import_method TEXT NOT NULL CHECK (import_method IN ('healthkit', 'ocr', 'manual', 'csv')),
-- Core metrics (all nullable; not every source provides every field)
weight_kg REAL,
body_fat_pct REAL,
body_fat_mass_kg REAL,
lean_mass_kg REAL,
skeletal_muscle_mass_kg REAL,
bmi REAL,
visceral_fat_level INTEGER,
bone_mineral_content_kg REAL,
bone_mineral_density REAL,
resting_energy_kcal REAL,
basal_metabolic_rate_kcal REAL,
ecw_ratio REAL,
waist_hip_ratio REAL,
inbody_score INTEGER,
-- Extended data
segmental_data TEXT, -- JSON blob for per-limb breakdown
raw_data TEXT, -- JSON blob for anything source-specific
notes TEXT,
-- OCR metadata (Phase 2, nullable for now)
ocr_confidence REAL,
ocr_image_path TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_bodycomp_user_date ON body_composition_scans(user_id, measured_at DESC);
CREATE INDEX idx_bodycomp_user_source ON body_composition_scans(user_id, source);
2.2 API Endpoints
Base: https://app.p247.io
Auth: x-api-key header (consistent with existing endpoints)
POST /bodycomp/
Create a new body composition scan entry (manual entry from app).
Request:
{
"source": "inbody",
"measured_at": "2026-02-24T10:30:00Z",
"import_method": "manual",
"weight_kg": 79.6,
"body_fat_pct": 13.7,
"body_fat_mass_kg": 10.9,
"lean_mass_kg": null,
"skeletal_muscle_mass_kg": 39.1,
"bmi": 25.7,
"visceral_fat_level": 4,
"basal_metabolic_rate_kcal": 1854,
"ecw_ratio": 0.375,
"waist_hip_ratio": 0.86,
"inbody_score": 91,
"segmental_data": {
"right_arm_lean": 3.8,
"left_arm_lean": 3.7,
"trunk_lean": 25.2,
"right_leg_lean": 10.1,
"left_leg_lean": 10.0
},
"notes": "Post-holiday scan"
}
Response (201):
{
"id": "a1b2c3d4e5f6...",
"source": "inbody",
"accuracy_tier": 2,
"measured_at": "2026-02-24T10:30:00Z",
"weight_kg": 79.6,
"body_fat_pct": 13.7,
"skeletal_muscle_mass_kg": 39.1,
"created_at": "2026-03-26T00:15:00Z"
}
Logic:
accuracy_tieris set automatically based onsource: dexa=1, inbody=2, smart_scale=3- Validate at least one metric field is provided
- Deduplicate: reject if a scan from the same source exists within 1 hour of
measured_at
POST /bodycomp/healthkit
Batch ingest body comp samples from HealthKit. Called by the iOS app during normal HealthKit sync (extends the existing /sync/healthkit flow, or can be a dedicated endpoint).
Request:
{
"samples": [
{
"measured_at": "2026-03-26T06:15:00Z",
"weight_kg": 78.1,
"body_fat_pct": 11.5,
"lean_mass_kg": 69.1,
"bmi": 25.2,
"resting_energy_kcal": 1832
},
{
"measured_at": "2026-03-25T06:20:00Z",
"weight_kg": 78.3,
"body_fat_pct": 11.8,
"lean_mass_kg": 69.0,
"bmi": 25.3,
"resting_energy_kcal": 1828
}
]
}
Response (200):
{
"inserted": 2,
"duplicates_skipped": 0
}
Logic:
- All samples get
source: "smart_scale",accuracy_tier: 3,import_method: "healthkit" - Deduplicate on
measured_atwithin a 30-minute window for smart_scale source - Upsert: if a smart_scale reading exists within the window, update it rather than create a duplicate
GET /bodycomp/latest
Get the most recent scan from the highest available accuracy tier. This powers the headline card in the app.
Response (200):
{
"headline": {
"source": "inbody",
"accuracy_tier": 2,
"measured_at": "2026-02-24T10:30:00Z",
"days_ago": 30,
"weight_kg": 79.6,
"body_fat_pct": 13.7,
"body_fat_mass_kg": 10.9,
"skeletal_muscle_mass_kg": 39.1,
"bmi": 25.7,
"visceral_fat_level": 4,
"inbody_score": 91,
"basal_metabolic_rate_kcal": 1854
},
"latest_scale": {
"source": "smart_scale",
"measured_at": "2026-03-26T06:15:00Z",
"weight_kg": 78.1,
"body_fat_pct": 11.5,
"lean_mass_kg": 69.1,
"bmi": 25.2
},
"has_dexa": false,
"has_inbody": true,
"has_smart_scale": true,
"scan_count": {
"dexa": 0,
"inbody": 3,
"smart_scale": 45
}
}
Logic:
headline= most recent scan from the highest available tier (DEXA > InBody > smart_scale)latest_scale= most recent smart_scale reading (always included if available, for the “current weight” display)has_*flags tell the app which import options to highlightscan_countper source for the history screen
GET /bodycomp/trend?period=30d&metric=weight_kg
Get time-series data for the rolling trend chart.
Query params:
| Param | Required | Values | Default |
|—|—|—|—|
| period | No | 7d, 30d, 90d, 180d, 1y | 30d |
| metrics | No | Comma-separated: weight_kg, body_fat_pct, lean_mass_kg, skeletal_muscle_mass_kg, bmi | weight_kg,body_fat_pct,lean_mass_kg |
Response (200):
{
"period": "30d",
"start_date": "2026-02-24",
"end_date": "2026-03-26",
"daily_data": [
{
"date": "2026-03-26",
"source": "smart_scale",
"weight_kg": 78.1,
"body_fat_pct": 11.5,
"lean_mass_kg": 69.1
},
{
"date": "2026-03-25",
"source": "smart_scale",
"weight_kg": 78.3,
"body_fat_pct": 11.8,
"lean_mass_kg": 69.0
}
],
"calibration_pins": [
{
"date": "2026-02-24",
"source": "inbody",
"accuracy_tier": 2,
"weight_kg": 79.6,
"body_fat_pct": 13.7,
"skeletal_muscle_mass_kg": 39.1,
"lean_mass_kg": null
}
],
"summary": {
"weight_kg": { "start": 79.6, "end": 78.1, "change": -1.5, "change_pct": -1.9 },
"body_fat_pct": { "start": 13.7, "end": 11.5, "change": -2.2 },
"lean_mass_kg": { "start": null, "end": 69.1, "change": null }
}
}
Logic:
daily_data= one entry per day from smart_scale readings (use earliest reading in the 04:00-12:00 local morning window; fallback to earliest of day if no morning sample)calibration_pins= all InBody and DEXA scans within the period, returned separately so the app can overlay them as pinssummary= period start/end values and delta (start value comes from the first available reading in the period, or the most recent calibration scan if it falls on/before the start date)- If no smart_scale data exists for a date, that date is omitted (no interpolation)
GET /bodycomp/history?source=inbody&limit=20&offset=0
List all scans, optionally filtered by source.
Query params:
| Param | Required | Values | Default |
|—|—|—|—|
| source | No | dexa, inbody, smart_scale, all | all |
| limit | No | 1-100 | 20 |
| offset | No | 0+ | 0 |
Response (200):
{
"scans": [
{
"id": "a1b2c3d4...",
"source": "inbody",
"accuracy_tier": 2,
"measured_at": "2026-02-24T10:30:00Z",
"import_method": "manual",
"weight_kg": 79.6,
"body_fat_pct": 13.7,
"skeletal_muscle_mass_kg": 39.1,
"bmi": 25.7,
"visceral_fat_level": 4,
"inbody_score": 91,
"notes": "Post-holiday scan"
}
],
"total": 48,
"limit": 20,
"offset": 0
}
GET /bodycomp/{id}
Get full detail for a single scan (tapping a pin or history item).
Response (200): Full scan object with all fields including segmental_data and raw_data.
PUT /bodycomp/{id}
Update a scan entry (correct a manual entry error).
Request: Same shape as POST, partial update (only send fields to change).
Response (200): Updated scan object.
DELETE /bodycomp/{id}
Delete a scan entry.
Response (204): No content.
2.3 Integration with Existing Endpoints
/sync/healthkit update: The existing HealthKit sync endpoint already receives body_fat_pct, weight_kg, and lean_body_mass as latest-value metrics. Two options:
Option A (recommended): Parse body comp fields from the existing /sync/healthkit payload and auto-create body_composition_scans entries. No iOS changes needed for smart scale ingestion.
Option B: Separate /bodycomp/healthkit endpoint (as spec’d above). Requires the iOS app to send body comp data separately.
Recommendation: Option A for Phase 1 (zero iOS work for smart scale data). The backend detects body comp fields in the HealthKit payload and writes them to body_composition_scans as a side effect. The dedicated /bodycomp/healthkit endpoint can be added in Phase 2 if finer control is needed.
/brief/today update: The existing body_composition block in the brief response currently has hardcoded InBody data. Update it to pull from GET /bodycomp/latest logic (highest-tier headline).
3. iOS App Screens
3.1 Body Composition Card (Home / Trends Screen)
Lives on the existing Trends screen or as a section on the Home screen.
┌─────────────────────────────────────┐
│ Body Composition │
│ Source: InBody · 24 Feb 2026 │
│ │
│ 79.6 kg 13.7% 39.1 kg │
│ Weight Body Fat SMM │
│ │
│ 25.7 4/9 91 │
│ BMI Visceral Fat InBody │
│ │
│ ─── Scale today: 78.1 kg ─── │
│ │
│ [View Trends] [Add Scan] │
└─────────────────────────────────────┘
Behaviour:
- Headline row shows data from the highest-accuracy scan (via
/bodycomp/latest→headline) - Source label + date shown under the title
- “Scale today” line shows
latest_scale.weight_kgif available (grey/secondary text) - If no InBody/DEXA exists, the card shows smart scale data as headline (with a note: “For more accurate readings, import an InBody or DEXA scan”)
[View Trends]opens the trend chart screen[Add Scan]opens the scan type selector
3.2 Trend Chart Screen
┌─────────────────────────────────────┐
│ ← Body Composition Trends │
│ │
│ [7d] [30d] [90d] │
│ │
│ Weight (kg) │
│ ┌─────────────────────────────┐ │
│ │ ▼ InBody │ │
│ │ ╱╲ ╱╲ │ │
│ │ ──╱──╲╱──╲────────────── │ │
│ │ │ │
│ └─────────────────────────────┘ │
│ 78.1 kg (-1.5 since InBody) │
│ │
│ Body Fat % │
│ ┌─────────────────────────────┐ │
│ │ ▼ InBody │ │
│ │ ──╲────────────────────── │ │
│ │ │ │
│ └─────────────────────────────┘ │
│ 11.5% (scale) · 13.7% (InBody) │
│ │
│ Lean Mass (kg) │
│ ┌─────────────────────────────┐ │
│ │ ─────────────────────── │ │
│ └─────────────────────────────┘ │
│ 69.1 kg │
│ │
│ [Scan History] │
└─────────────────────────────────────┘
Behaviour:
- Period selector: 7d / 30d / 90d (maps to
/bodycomp/trend?period=) - Three stacked line charts: Weight, Body Fat %, Lean Mass
- Daily smart scale data = continuous line
- InBody/DEXA scans = pin markers (▼) on the chart, different colours per source:
- InBody pins: blue/silver
- DEXA pins: gold
- Tapping a pin shows a tooltip with the full scan values from that date
- Below each chart: current value + delta from last calibration scan
- Note under Body Fat %: show both scale and InBody values side by side (the user knows these differ)
[Scan History]opens the full list
3.3 Add Scan Screen (Manual Entry)
┌─────────────────────────────────────┐
│ ← Add Scan │
│ │
│ What type of scan? │
│ │
│ ┌──────────┐ ┌──────────┐ │
│ │ InBody │ │ DEXA │ │
│ │ (BIA) │ │ (X-ray) │ │
│ └──────────┘ └──────────┘ │
│ │
│ [📷 Scan Printout] ← Phase 2 │
│ (greyed out with "Coming soon") │
│ │
└─────────────────────────────────────┘
After selecting InBody:
┌─────────────────────────────────────┐
│ ← InBody Scan Entry │
│ │
│ Date │
│ [24 Feb 2026 ▼] │
│ │
│ ── Core Metrics ── │
│ Weight (kg) [79.6 ] │
│ Body Fat % [13.7 ] │
│ Body Fat Mass (kg) [10.9 ] │
│ SMM (kg) [39.1 ] │
│ BMI [25.7 ] │
│ │
│ ── Additional ── │
│ Visceral Fat (1-9) [4 ] │
│ InBody Score [91 ] │
│ BMR (kcal) [1854 ] │
│ ECW Ratio [0.375 ] │
│ Waist-Hip Ratio [0.86 ] │
│ │
│ Notes │
│ [Post-holiday scan ] │
│ │
│ [Save Scan] │
└─────────────────────────────────────┘
After selecting DEXA:
┌─────────────────────────────────────┐
│ ← DEXA Scan Entry │
│ │
│ Date │
│ [26 Mar 2026 ▼] │
│ │
│ ── Core Metrics ── │
│ Weight (kg) [ ] │
│ Body Fat % [ ] │
│ Body Fat Mass (kg) [ ] │
│ Lean Mass (kg) [ ] │
│ BMI [ ] │
│ │
│ ── Bone Density ── │
│ Bone Mineral (kg) [ ] │
│ Bone Density [ ] │
│ │
│ ── Additional ── │
│ Visceral Fat Area [ ] │
│ │
│ Notes │
│ [ ] │
│ │
│ [Save Scan] │
└─────────────────────────────────────┘
Behaviour:
- Date picker defaults to today
- Core metrics section first (most important fields at the top)
- Additional metrics section for secondary fields
- Numeric keyboards for all input fields
- “Scan Printout” button visible but greyed out with “Coming soon” label (Phase 2 teaser)
- Validate: at least weight OR body fat % must be provided
- On save:
POST /bodycomp/withimport_method: "manual"
3.4 Scan History Screen
┌─────────────────────────────────────┐
│ ← Scan History │
│ │
│ [All] [InBody] [DEXA] [Scale] │
│ │
│ ┌─────────────────────────────┐ │
│ │ InBody · 24 Feb 2026 │ │
│ │ 79.6 kg · 13.7% BF · 39.1 │ │
│ │ SMM · Score: 91 │ │
│ └─────────────────────────────┘ │
│ │
│ ┌─────────────────────────────┐ │
│ │ InBody · 21 Feb 2026 │ │
│ │ 79.2 kg · 15.0% BF · 38.0 │ │
│ │ SMM · Score: 88 │ │
│ └─────────────────────────────┘ │
│ │
│ ┌─────────────────────────────┐ │
│ │ InBody · 08 Nov 2025 │ │
│ │ 80.3 kg · 11.2% BF · 40.8 │ │
│ │ SMM · Score: 93 │ │
│ └─────────────────────────────┘ │
│ │
└─────────────────────────────────────┘
Behaviour:
- Filter tabs: All, InBody, DEXA, Scale
- Scale tab only shows daily readings (collapsed by week to avoid 365 cards)
- Tapping a card opens the full detail view (
GET /bodycomp/{id}) - Swipe to delete (with confirmation)
3.5 Scan Detail Screen
Full detail view when tapping a scan from history or a chart pin.
Shows all available fields for that scan type, laid out similarly to the manual entry form but read-only, with an “Edit” button in the nav bar.
4. HealthKitManager.swift Changes
The existing HealthKitManager.swift already reads bodyMass, bodyFatPercentage, and leanBodyMass. For Phase 1 with Option A (recommended), no iOS HealthKit changes are needed. The backend parses these from the existing /sync/healthkit payload.
If Option B is chosen, add this to the sync flow:
// In syncDate(), after building the metrics dict:
// Body composition — send separately to dedicated endpoint
var bodyCompSamples: [[String: Any]] = []
if let weight = metrics["weight_kg"] as? Double {
var sample: [String: Any] = [
"measured_at": ISO8601DateFormatter().string(from: startOfDay),
"weight_kg": weight
]
if let bf = metrics["body_fat_pct"] as? Double { sample["body_fat_pct"] = bf }
if let lbm = metrics["lean_body_mass"] as? Double { sample["lean_mass_kg"] = lbm }
if let bmi = metrics["bmi"] as? Double { sample["bmi"] = bmi } // Note: add BMI to latestMetrics if not already
if let re = metrics["basal_calories"] as? Double { sample["resting_energy_kcal"] = re }
bodyCompSamples.append(sample)
}
if !bodyCompSamples.isEmpty {
await postBodyComp(samples: bodyCompSamples)
}
Add a new screen for manual entry. This needs:
- A new SwiftUI view:
BodyCompEntryView - A scan type selector:
ScanTypePickerView - The trend chart: use Swift Charts framework (
import Charts)
5. Existing Data Migration
Myles has 3 InBody scans already recorded (8 Nov 2025, 21 Feb 2026, 24 Feb 2026). These need to be seeded into body_composition_scans as part of the migration.
-- Seed existing InBody scans
INSERT INTO body_composition_scans (
id, user_id, source, accuracy_tier, measured_at, import_method,
weight_kg, body_fat_pct, body_fat_mass_kg, skeletal_muscle_mass_kg,
bmi, visceral_fat_level, inbody_score, basal_metabolic_rate_kcal
) VALUES
('seed_inbody_001', '<MYLES_USER_ID>', 'inbody', 2, '2025-11-08T00:00:00Z', 'manual',
80.3, 11.2, NULL, 40.8, NULL, NULL, NULL, NULL),
('seed_inbody_002', '<MYLES_USER_ID>', 'inbody', 2, '2026-02-21T00:00:00Z', 'manual',
79.2, 15.0, NULL, 38.0, NULL, NULL, NULL, NULL),
('seed_inbody_003', '<MYLES_USER_ID>', 'inbody', 2, '2026-02-24T00:00:00Z', 'manual',
79.6, 13.7, 10.9, 39.1, 25.7, 4, 91, 1854);
6. Implementation Checklist
Backend
- Create
body_composition_scanstable + indexes POST /bodycomp/— manual entry endpointPOST /bodycomp/healthkit— batch HealthKit ingest (or Option A: parse from existing/sync/healthkit)GET /bodycomp/latest— headline card dataGET /bodycomp/trend— rolling trend with calibration pinsGET /bodycomp/history— paginated scan listGET /bodycomp/{id}— scan detailPUT /bodycomp/{id}— update scanDELETE /bodycomp/{id}— delete scan- Update
/brief/todayto pull body comp from new table - Seed Myles’s 3 existing InBody scans
- Add body comp endpoints to API docs / Swagger
iOS App
- Body Composition card on Home/Trends screen
- Trend chart screen (Swift Charts, 7d/30d/90d toggle, pin overlays)
- Scan type picker (InBody / DEXA, with greyed-out OCR teaser)
- InBody manual entry form
- DEXA manual entry form
- Scan history list (filterable by source)
- Scan detail view
- Edit scan flow
- Delete scan (swipe + confirm)
- Wire up to
/bodycomp/*endpoints
7. Decisions (Resolved 26 Mar 2026)
-
Chart library: Swift Charts (native, iOS 16+). Add a thin chart adapter layer so swapping to a third-party lib later is low-risk.
-
Multiple smart-scale readings per day: Use earliest reading in a morning window (local 04:00 to 12:00). Fallback to earliest reading of the day if no morning sample exists. More stable than averaging and matches real weigh-in behaviour.
-
Body comp in daily brief: Deferred to Phase 1.1 (not blocking Phase 1 release). Gate trend text generation behind data quality rules: minimum 5 days of readings in the last 7, otherwise skip trend wording entirely.