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_scans table

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_tier is set automatically based on source: 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_at within 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 highlight
  • scan_count per 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 pins
  • summary = 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

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/latestheadline)
  • Source label + date shown under the title
  • “Scale today” line shows latest_scale.weight_kg if 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/ with import_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_scans table + indexes
  • POST /bodycomp/ — manual entry endpoint
  • POST /bodycomp/healthkit — batch HealthKit ingest (or Option A: parse from existing /sync/healthkit)
  • GET /bodycomp/latest — headline card data
  • GET /bodycomp/trend — rolling trend with calibration pins
  • GET /bodycomp/history — paginated scan list
  • GET /bodycomp/{id} — scan detail
  • PUT /bodycomp/{id} — update scan
  • DELETE /bodycomp/{id} — delete scan
  • Update /brief/today to 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)

  1. Chart library: Swift Charts (native, iOS 16+). Add a thin chart adapter layer so swapping to a third-party lib later is low-risk.

  2. 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.

  3. 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.