
Health Data
- 360 installs
- 339 repo stars
- Updated August 4, 2026
- glebis/claude-skills
health-data is a Claude agent skill that queries a local Apple Health SQLite database across 43 metric types and exports Markdown, JSON, FHIR R4, or ASCII reports for developers building wellness, clinical, or benefits a
About
health-data is a glebis/claude-skills agent skill for querying and analyzing Apple Health data stored in a local SQLite database containing 6.3M+ records across 43 health metric types. The health_query.py script supports daily summaries, weekly trends, sleep analysis, vitals, activity rings, workouts, and raw SQL templates. Output formats include Markdown, JSON, FHIR R4 Bundles with LOINC-coded Observations, and ASCII Unicode bar charts. Developers reach for health-data when building health apps, generating fitness reports, exporting interoperable FHIR data, or validating privacy-aware query patterns for clinical and benefits workflows. Reference files cover database schema, FHIR mappings, and pre-built SQL query templates.
- Health schema patterns
- FHIR-oriented transforms
- Privacy-aware handling
- Metrics and cohort queries
- Pipeline-ready structures
Health Data by the numbers
- 360 all-time installs (skills.sh)
- Ranked #531 of 2,064 Data Science & ML skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/glebis/claude-skills --skill health-dataAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 360 |
|---|---|
| repo stars | ★ 339 |
| Last updated | August 4, 2026 |
| Repository | glebis/claude-skills ↗ |
How do you export Apple Health data as FHIR R4?
Model, validate, transform, and query health datasets (FHIR-like records, vitals, claims) with privacy-aware patterns for wellness, clinical, or benefits apps.
Who is it for?
Developers building health, wellness, or clinical apps who need to query local Apple Health SQLite data and export FHIR R4 interoperable records.
Skip if: Teams without a local Apple Health SQLite export who need cloud EHR integration rather than on-device health database analysis.
When should I use this skill?
User asks to analyze Apple Health metrics, generate health reports, export FHIR data, or query vitals and sleep patterns from SQLite.
What you get
Formatted health reports in Markdown, JSON, FHIR R4 Bundle, or ASCII charts from SQLite queries across vitals, sleep, and workouts.
- Health query reports
- FHIR R4 Bundle exports
- ASCII or JSON trend visualizations
By the numbers
- Queries 6.3M+ health records across 43 metric types
- Exports 4 output formats: Markdown, JSON, FHIR R4, and ASCII
- FHIR R4 output uses LOINC-coded Observation resources per fhir_mappings.md
Files
Apple Health Data Query Skill
Query and analyze health data from the local SQLite database containing 6.3M+ records across 43 health metrics.
Database Location
~/data/health.dbQuery Methods
1. Python Script (Recommended for Common Queries)
Use scripts/health_query.py for pre-built queries with automatic formatting:
# Daily summary
python ~/.claude/skills/health-data/scripts/health_query.py --format markdown daily --date 2025-11-29
# Weekly trends
python ~/.claude/skills/health-data/scripts/health_query.py --format json weekly --weeks 4
# Sleep analysis
python ~/.claude/skills/health-data/scripts/health_query.py --format fhir sleep --days 7
# Latest vitals
python ~/.claude/skills/health-data/scripts/health_query.py vitals
# Activity rings
python ~/.claude/skills/health-data/scripts/health_query.py --format json activity --days 30
# Workout history
python ~/.claude/skills/health-data/scripts/health_query.py workouts --days 30 --type Running
# Custom SQL
python ~/.claude/skills/health-data/scripts/health_query.py --format json query "SELECT * FROM workouts LIMIT 5"Output formats: markdown, json, fhir, ascii
2. Direct SQL (For Custom/Ad-hoc Queries)
For flexible queries, run SQL directly against the database. See references/schema.md for table structures and query templates.
sqlite3 ~/data/health.db "SELECT AVG(value) FROM health_records WHERE record_type LIKE '%HeartRate%' AND start_date LIKE '2025-11%'"Pre-built Queries
Daily Health Summary
Get today's key metrics:
python ~/.claude/skills/health-data/scripts/health_query.py dailyReturns: steps, calories, heart rate (avg/min/max), exercise minutes, distance, activity ring status.
Weekly Trends
Compare week-over-week performance:
python ~/.claude/skills/health-data/scripts/health_query.py weekly --weeks 4Returns: average daily steps, resting HR, exercise minutes, workout count per week.
Sleep Analysis
Analyze sleep patterns:
python ~/.claude/skills/health-data/scripts/health_query.py sleep --days 14Returns: nightly duration, sleep stages (Core, Deep, REM), average sleep hours.
Latest Vitals
Get most recent vital readings:
python ~/.claude/skills/health-data/scripts/health_query.py vitalsReturns: Heart Rate, HRV, Resting HR, Blood Oxygen, Respiratory Rate with timestamps.
Activity Rings
Track ring completion:
python ~/.claude/skills/health-data/scripts/health_query.py activity --days 30Returns: daily ring values/goals, completion percentages, perfect day count.
Workout History
Review exercise sessions:
python ~/.claude/skills/health-data/scripts/health_query.py workouts --days 30 --type RunningReturns: workout type, duration, distance, calories, summary by type.
Output Formats
Markdown (default)
Human-readable tables and lists. Best for reports and summaries.
JSON
Structured data for programmatic use:
{
"date": "2025-11-29",
"metrics": {
"steps": 8542,
"active_calories": 450.5,
"heart_rate": {"avg": 72.3, "min": 52, "max": 145}
}
}FHIR R4
Healthcare interoperability format. Outputs as FHIR Bundle with Observation resources using LOINC codes. See references/fhir_mappings.md for code mappings.
ASCII
Terminal-friendly output with bar charts and statistics:
============================================================
DAILY SUMMARY - 2025-11-29
============================================================
METRICS
----------------------------------------
steps 2620
active_calories 234.5
heart_rate avg: 67.5 min: 52 max: 108
ACTIVITY RINGS
----------------------------------------
move [███████░░░░░░░░░░░░░] 36.7% (238/650)
exercise [░░░░░░░░░░░░░░░░░░░░] 0.0% (0/35)
stand [████████████████████] 100.0% (10/10)Common SQL Patterns
For ad-hoc queries, use these patterns from references/schema.md:
Heart rate by hour (circadian pattern):
SELECT strftime('%H', start_date) as hour, ROUND(AVG(value), 1) as avg_hr
FROM health_records
WHERE record_type = 'HKQuantityTypeIdentifierHeartRate'
AND value BETWEEN 40 AND 200
GROUP BY hour ORDER BY hour;Steps per day this month:
SELECT DATE(start_date) as day, SUM(value) as steps
FROM health_records
WHERE record_type = 'HKQuantityTypeIdentifierStepCount'
AND start_date >= DATE('now', 'start of month')
GROUP BY day ORDER BY day;Sleep quality (deep + REM hours):
SELECT DATE(start_date) as night,
ROUND(SUM(duration_minutes)/60.0, 1) as quality_hours
FROM sleep_sessions
WHERE sleep_stage IN ('Deep', 'REM')
GROUP BY night ORDER BY night DESC LIMIT 14;Workout summary:
SELECT REPLACE(workout_type, 'HKWorkoutActivityType', '') as type,
COUNT(*) as count, ROUND(SUM(duration_minutes)) as total_min
FROM workouts
WHERE start_date >= DATE('now', '-30 days')
GROUP BY type ORDER BY count DESC;Record Types Available
The database contains 43 health metric types including:
Vitals: Heart Rate, HRV, Resting HR, Blood Oxygen, Respiratory Rate, Blood Pressure
Activity: Steps, Distance, Active Calories, Basal Calories, Flights Climbed, Exercise Time, Stand Time
Mobility: Walking Speed, Step Length, Walking Asymmetry, Stair Speed, Walking Steadiness
Body: Weight, BMI, Body Fat %
Audio: Environmental Noise, Headphone Exposure
Other: VO2 Max, Time in Daylight, UV Exposure
Data Coverage
- Records: 6.3M+ measurements
- Date range: 2015-10-13 to present
- Workouts: 1,435 sessions
- Sleep sessions: 40,514 records
- Activity days: 1,875 daily summaries
Resources
scripts/
health_query.py- Main query tool with Markdown/JSON/FHIR output
references/
schema.md- Database schema, record type mappings, SQL query templatesfhir_mappings.md- LOINC codes and FHIR R4 templates
Troubleshooting
Database not found: Ensure ~/data/health.db exists. Run the import script from /Users/server/apple_health_export/:
python import_health.py --statusNo data for date range: Check available date range:
SELECT MIN(start_date), MAX(start_date) FROM health_records;Outlier values: Filter physiologically valid ranges (e.g., heart rate 40-200 bpm):
WHERE value BETWEEN 40 AND 200{
"name": "health-data",
"description": "Query Apple Health SQLite database for vitals, activity, sleep, and workouts. Supports Markdown, JSON, and FHIR R4 outpu",
"author": {
"name": "Gleb Kalinin"
},
"repository": "https://github.com/glebis/claude-skills",
"license": "MIT"
}FHIR R4 Mappings for Apple Health Data
LOINC Code Mappings
| Apple Health Type | LOINC Code | Display Name | UCUM Unit |
|---|---|---|---|
| HKQuantityTypeIdentifierHeartRate | 8867-4 | Heart rate | /min |
| HKQuantityTypeIdentifierHeartRateVariabilitySDNN | 80404-7 | R-R interval.standard deviation | ms |
| HKQuantityTypeIdentifierRestingHeartRate | 40443-4 | Resting heart rate | /min |
| HKQuantityTypeIdentifierWalkingHeartRateAverage | 89270-3 | Walking heart rate | /min |
| HKQuantityTypeIdentifierOxygenSaturation | 59408-5 | Oxygen saturation in Arterial blood by Pulse oximetry | % |
| HKQuantityTypeIdentifierRespiratoryRate | 9279-1 | Respiratory rate | /min |
| HKQuantityTypeIdentifierBodyTemperature | 8310-5 | Body temperature | Cel |
| HKQuantityTypeIdentifierBloodPressureSystolic | 8480-6 | Systolic blood pressure | mm[Hg] |
| HKQuantityTypeIdentifierBloodPressureDiastolic | 8462-4 | Diastolic blood pressure | mm[Hg] |
| HKQuantityTypeIdentifierBodyMass | 29463-7 | Body weight | kg |
| HKQuantityTypeIdentifierHeight | 8302-2 | Body height | cm |
| HKQuantityTypeIdentifierBodyMassIndex | 39156-5 | Body mass index | kg/m2 |
| HKQuantityTypeIdentifierStepCount | 55423-8 | Number of steps in unspecified time Pedometer | {steps} |
| HKQuantityTypeIdentifierDistanceWalkingRunning | 41953-1 | Walking distance | km |
| HKQuantityTypeIdentifierActiveEnergyBurned | 41981-2 | Calories burned | kcal |
| HKQuantityTypeIdentifierFlightsClimbed | 93831-6 | Flights of stairs climbed | {flights} |
| HKQuantityTypeIdentifierVO2Max | 60842-2 | Oxygen consumption (VO2 max) | mL/min/kg |
| HKCategoryTypeIdentifierSleepAnalysis | 93832-4 | Sleep duration | h |
FHIR Observation Categories
| Category Code | Display | Used For |
|---|---|---|
| vital-signs | Vital Signs | HR, SpO2, BP, Temp, RR |
| activity | Activity | Steps, Distance, Calories, Exercise |
| sleep-wake | Sleep/Wake | Sleep analysis |
FHIR R4 Observation Template
{
"resourceType": "Observation",
"id": "<uuid>",
"meta": {
"profile": ["http://hl7.org/fhir/StructureDefinition/vitalsigns"]
},
"status": "final",
"category": [{
"coding": [{
"system": "http://terminology.hl7.org/CodeSystem/observation-category",
"code": "vital-signs",
"display": "Vital Signs"
}]
}],
"code": {
"coding": [{
"system": "http://loinc.org",
"code": "<LOINC_CODE>",
"display": "<DISPLAY_NAME>"
}],
"text": "<FRIENDLY_NAME>"
},
"subject": {
"reference": "Patient/self"
},
"effectiveDateTime": "<ISO_TIMESTAMP>",
"valueQuantity": {
"value": <NUMERIC_VALUE>,
"unit": "<DISPLAY_UNIT>",
"system": "http://unitsofmeasure.org",
"code": "<UCUM_CODE>"
}
}FHIR Bundle Template
For multiple observations:
{
"resourceType": "Bundle",
"id": "<uuid>",
"type": "collection",
"timestamp": "<ISO_TIMESTAMP>",
"entry": [
{
"fullUrl": "urn:uuid:<observation-uuid>",
"resource": { /* Observation */ }
}
]
}Heart Rate Observation Example
{
"resourceType": "Observation",
"id": "hr-20251129-143000",
"status": "final",
"category": [{
"coding": [{
"system": "http://terminology.hl7.org/CodeSystem/observation-category",
"code": "vital-signs",
"display": "Vital Signs"
}]
}],
"code": {
"coding": [{
"system": "http://loinc.org",
"code": "8867-4",
"display": "Heart rate"
}],
"text": "Heart Rate"
},
"subject": {
"reference": "Patient/self"
},
"effectiveDateTime": "2025-11-29T14:30:00+01:00",
"valueQuantity": {
"value": 72,
"unit": "beats/minute",
"system": "http://unitsofmeasure.org",
"code": "/min"
}
}Sleep Duration Observation Example
{
"resourceType": "Observation",
"id": "sleep-20251129",
"status": "final",
"category": [{
"coding": [{
"system": "http://terminology.hl7.org/CodeSystem/observation-category",
"code": "sleep-wake",
"display": "Sleep"
}]
}],
"code": {
"coding": [{
"system": "http://loinc.org",
"code": "93832-4",
"display": "Sleep duration"
}],
"text": "Sleep Duration"
},
"effectivePeriod": {
"start": "2025-11-28T23:30:00+01:00",
"end": "2025-11-29T07:15:00+01:00"
},
"valueQuantity": {
"value": 7.75,
"unit": "hours",
"system": "http://unitsofmeasure.org",
"code": "h"
},
"component": [
{
"code": {
"coding": [{
"system": "http://loinc.org",
"code": "93831-0",
"display": "Deep sleep duration"
}]
},
"valueQuantity": {
"value": 1.5,
"unit": "hours",
"system": "http://unitsofmeasure.org",
"code": "h"
}
},
{
"code": {
"coding": [{
"system": "http://loinc.org",
"code": "93830-2",
"display": "REM sleep duration"
}]
},
"valueQuantity": {
"value": 2.0,
"unit": "hours",
"system": "http://unitsofmeasure.org",
"code": "h"
}
}
]
}Activity Bundle Example
{
"resourceType": "Bundle",
"id": "daily-activity-20251129",
"type": "collection",
"timestamp": "2025-11-29T23:59:59Z",
"entry": [
{
"fullUrl": "urn:uuid:steps-20251129",
"resource": {
"resourceType": "Observation",
"id": "steps-20251129",
"status": "final",
"category": [{
"coding": [{
"system": "http://terminology.hl7.org/CodeSystem/observation-category",
"code": "activity",
"display": "Activity"
}]
}],
"code": {
"coding": [{
"system": "http://loinc.org",
"code": "55423-8",
"display": "Number of steps"
}]
},
"effectiveDateTime": "2025-11-29",
"valueQuantity": {
"value": 8542,
"unit": "steps",
"system": "http://unitsofmeasure.org",
"code": "{steps}"
}
}
},
{
"fullUrl": "urn:uuid:calories-20251129",
"resource": {
"resourceType": "Observation",
"id": "calories-20251129",
"status": "final",
"category": [{
"coding": [{
"system": "http://terminology.hl7.org/CodeSystem/observation-category",
"code": "activity",
"display": "Activity"
}]
}],
"code": {
"coding": [{
"system": "http://loinc.org",
"code": "41981-2",
"display": "Calories burned"
}]
},
"effectiveDateTime": "2025-11-29",
"valueQuantity": {
"value": 2150,
"unit": "kcal",
"system": "http://unitsofmeasure.org",
"code": "kcal"
}
}
}
]
}References
Apple Health Database Schema
Database location: ~/data/health.db
Tables
health_records
Main table containing 6.3M+ health measurements across 43 types.
| Column | Type | Description |
|---|---|---|
| id | INTEGER | Primary key |
| record_type | TEXT | HK identifier (e.g., HKQuantityTypeIdentifierHeartRate) |
| value | REAL | Numeric value |
| value_text | TEXT | String value (for categories) |
| unit | TEXT | Unit of measurement |
| start_date | TEXT | ISO timestamp with timezone |
| end_date | TEXT | End timestamp (if applicable) |
| start_ts | INTEGER | Unix timestamp for fast queries |
| end_ts | INTEGER | End unix timestamp |
| source_id | INTEGER | FK to sources table |
| source_name | TEXT | Device/app name |
| device | TEXT | Raw device string |
| creation_date | TEXT | When record was created |
Indexes: idx_records_type, idx_records_start_date, idx_records_start_ts, idx_records_type_date, idx_records_type_ts
workouts
Exercise sessions with metadata.
| Column | Type | Description |
|---|---|---|
| id | INTEGER | Primary key |
| workout_type | TEXT | HKWorkoutActivityType* |
| duration_minutes | REAL | Duration |
| total_distance | REAL | Distance traveled |
| distance_unit | TEXT | km, mi, etc. |
| total_energy_burned | REAL | Calories |
| energy_unit | TEXT | kcal |
| start_date | TEXT | Start timestamp |
| end_date | TEXT | End timestamp |
| source_name | TEXT | Recording app |
| route_file | TEXT | GPX file reference |
sleep_sessions
Sleep analysis with stages.
| Column | Type | Description |
|---|---|---|
| id | INTEGER | Primary key |
| start_date | TEXT | Sleep start |
| end_date | TEXT | Sleep end |
| duration_minutes | REAL | Duration |
| sleep_stage | TEXT | InBed, Asleep, Awake, Core, Deep, REM |
| source_name | TEXT | Tracking source |
activity_summaries
Daily activity ring data.
| Column | Type | Description |
|---|---|---|
| id | INTEGER | Primary key |
| date | TEXT | YYYY-MM-DD |
| active_energy_burned | REAL | Move ring calories |
| active_energy_goal | REAL | Move goal |
| exercise_time_minutes | REAL | Exercise ring |
| exercise_time_goal | REAL | Exercise goal (usually 30) |
| stand_hours | INTEGER | Stand ring |
| stand_hours_goal | INTEGER | Stand goal (usually 12) |
sources
Device/app metadata (normalized).
| Column | Type | Description |
|---|---|---|
| id | INTEGER | Primary key |
| name | TEXT | App/device name |
| version | TEXT | Software version |
| device_name | TEXT | e.g., "Apple Watch" |
| device_model | TEXT | e.g., "Watch6,1" |
---
Record Type Mappings
| HK Identifier | Friendly Name | Unit |
|---|---|---|
| HKQuantityTypeIdentifierHeartRate | Heart Rate | count/min |
| HKQuantityTypeIdentifierHeartRateVariabilitySDNN | HRV | ms |
| HKQuantityTypeIdentifierRestingHeartRate | Resting HR | count/min |
| HKQuantityTypeIdentifierOxygenSaturation | Blood Oxygen | % |
| HKQuantityTypeIdentifierStepCount | Steps | count |
| HKQuantityTypeIdentifierDistanceWalkingRunning | Distance | km |
| HKQuantityTypeIdentifierActiveEnergyBurned | Active Calories | kcal |
| HKQuantityTypeIdentifierBasalEnergyBurned | Basal Calories | kcal |
| HKQuantityTypeIdentifierFlightsClimbed | Flights | count |
| HKQuantityTypeIdentifierAppleExerciseTime | Exercise Time | min |
| HKQuantityTypeIdentifierAppleStandTime | Stand Time | min |
| HKQuantityTypeIdentifierVO2Max | VO2 Max | mL/min·kg |
| HKQuantityTypeIdentifierRespiratoryRate | Respiratory Rate | count/min |
| HKQuantityTypeIdentifierBodyMass | Weight | kg |
| HKQuantityTypeIdentifierWalkingSpeed | Walking Speed | km/hr |
| HKQuantityTypeIdentifierWalkingStepLength | Step Length | cm |
| HKQuantityTypeIdentifierEnvironmentalAudioExposure | Noise Level | dBASPL |
| HKQuantityTypeIdentifierHeadphoneAudioExposure | Headphone Level | dBASPL |
| HKQuantityTypeIdentifierTimeInDaylight | Daylight Time | min |
---
SQL Query Templates
Time-Based Queries
Daily totals for a metric:
SELECT DATE(start_date) as day, SUM(value) as total
FROM health_records
WHERE record_type = 'HKQuantityTypeIdentifierStepCount'
AND start_date >= '2025-11-01'
GROUP BY day
ORDER BY day;Hourly averages (circadian pattern):
SELECT strftime('%H', start_date) as hour, AVG(value) as avg
FROM health_records
WHERE record_type = 'HKQuantityTypeIdentifierHeartRate'
AND value BETWEEN 40 AND 200
GROUP BY hour
ORDER BY hour;Weekly aggregation:
SELECT strftime('%Y-W%W', start_date) as week,
AVG(value) as avg, MIN(value) as min, MAX(value) as max
FROM health_records
WHERE record_type = 'HKQuantityTypeIdentifierHeartRate'
GROUP BY week
ORDER BY week DESC
LIMIT 12;Monthly trends:
SELECT strftime('%Y-%m', start_date) as month,
AVG(value) as avg, COUNT(*) as readings
FROM health_records
WHERE record_type = 'HKQuantityTypeIdentifierRestingHeartRate'
GROUP BY month
ORDER BY month DESC;Latest Values
Most recent reading per metric type:
SELECT record_type, value, unit, start_date
FROM health_records
WHERE id IN (
SELECT MAX(id) FROM health_records
WHERE record_type IN (
'HKQuantityTypeIdentifierHeartRate',
'HKQuantityTypeIdentifierOxygenSaturation',
'HKQuantityTypeIdentifierRestingHeartRate'
)
GROUP BY record_type
);Last N readings:
SELECT value, start_date
FROM health_records
WHERE record_type = 'HKQuantityTypeIdentifierHeartRate'
ORDER BY start_date DESC
LIMIT 10;Sleep Queries
Sleep duration by night:
SELECT DATE(start_date) as night,
SUM(duration_minutes) as total_min,
ROUND(SUM(duration_minutes)/60.0, 1) as hours
FROM sleep_sessions
WHERE sleep_stage IN ('Asleep', 'Core', 'Deep', 'REM')
GROUP BY night
ORDER BY night DESC
LIMIT 14;Sleep stage breakdown:
SELECT sleep_stage,
COUNT(*) as sessions,
ROUND(AVG(duration_minutes), 1) as avg_duration
FROM sleep_sessions
WHERE start_date >= DATE('now', '-30 days')
GROUP BY sleep_stage;Best sleep nights (most deep+REM):
SELECT DATE(start_date) as night,
SUM(CASE WHEN sleep_stage IN ('Deep', 'REM') THEN duration_minutes ELSE 0 END) as quality_min
FROM sleep_sessions
WHERE start_date >= DATE('now', '-30 days')
GROUP BY night
ORDER BY quality_min DESC
LIMIT 5;Workout Queries
Workout summary by type:
SELECT
REPLACE(workout_type, 'HKWorkoutActivityType', '') as type,
COUNT(*) as sessions,
ROUND(SUM(duration_minutes), 0) as total_min,
ROUND(SUM(total_energy_burned), 0) as total_cal
FROM workouts
WHERE start_date >= DATE('now', '-90 days')
GROUP BY type
ORDER BY sessions DESC;Workouts with distance:
SELECT
REPLACE(workout_type, 'HKWorkoutActivityType', '') as type,
DATE(start_date) as date,
ROUND(duration_minutes, 1) as minutes,
ROUND(total_distance, 2) as distance_km,
ROUND(total_energy_burned, 0) as calories
FROM workouts
WHERE total_distance > 0
ORDER BY start_date DESC
LIMIT 20;Activity Ring Queries
Ring completion rates:
SELECT
COUNT(*) as days,
ROUND(AVG(CASE WHEN active_energy_burned >= active_energy_goal THEN 100.0 ELSE active_energy_burned*100.0/active_energy_goal END), 1) as move_pct,
ROUND(AVG(CASE WHEN exercise_time_minutes >= exercise_time_goal THEN 100.0 ELSE exercise_time_minutes*100.0/exercise_time_goal END), 1) as exercise_pct,
ROUND(AVG(CASE WHEN stand_hours >= stand_hours_goal THEN 100.0 ELSE stand_hours*100.0/stand_hours_goal END), 1) as stand_pct
FROM activity_summaries
WHERE date >= DATE('now', '-30 days');Perfect ring days:
SELECT date, active_energy_burned, exercise_time_minutes, stand_hours
FROM activity_summaries
WHERE active_energy_burned >= active_energy_goal
AND exercise_time_minutes >= exercise_time_goal
AND stand_hours >= stand_hours_goal
ORDER BY date DESC;Correlation Queries
Heart rate vs. exercise correlation:
SELECT
a.date,
a.exercise_time_minutes,
ROUND(AVG(h.value), 1) as avg_hr
FROM activity_summaries a
JOIN health_records h ON DATE(h.start_date) = a.date
WHERE h.record_type = 'HKQuantityTypeIdentifierRestingHeartRate'
AND a.date >= DATE('now', '-90 days')
GROUP BY a.date;Sleep vs. next day HRV:
SELECT
s.night,
s.sleep_hours,
ROUND(AVG(h.value), 1) as next_day_hrv
FROM (
SELECT DATE(start_date) as night, SUM(duration_minutes)/60.0 as sleep_hours
FROM sleep_sessions
WHERE sleep_stage IN ('Asleep', 'Core', 'Deep', 'REM')
GROUP BY night
) s
JOIN health_records h ON DATE(h.start_date) = DATE(s.night, '+1 day')
WHERE h.record_type = 'HKQuantityTypeIdentifierHeartRateVariabilitySDNN'
GROUP BY s.night
ORDER BY s.night DESC
LIMIT 30;Statistical Queries
Percentiles:
SELECT
MIN(value) as min,
MAX(value) as max,
AVG(value) as mean,
(SELECT value FROM health_records
WHERE record_type = 'HKQuantityTypeIdentifierHeartRate'
AND value BETWEEN 40 AND 200
ORDER BY value LIMIT 1 OFFSET (SELECT COUNT(*)/2 FROM health_records WHERE record_type = 'HKQuantityTypeIdentifierHeartRate')) as median
FROM health_records
WHERE record_type = 'HKQuantityTypeIdentifierHeartRate'
AND value BETWEEN 40 AND 200;Day-of-week patterns:
SELECT
CASE strftime('%w', start_date)
WHEN '0' THEN 'Sunday'
WHEN '1' THEN 'Monday'
WHEN '2' THEN 'Tuesday'
WHEN '3' THEN 'Wednesday'
WHEN '4' THEN 'Thursday'
WHEN '5' THEN 'Friday'
WHEN '6' THEN 'Saturday'
END as day_name,
ROUND(AVG(value), 1) as avg
FROM health_records
WHERE record_type = 'HKQuantityTypeIdentifierHeartRate'
AND value BETWEEN 40 AND 200
GROUP BY strftime('%w', start_date)
ORDER BY strftime('%w', start_date);---
Quick Reference
Database connection:
sqlite3 ~/data/health.dbList all record types:
SELECT DISTINCT record_type, COUNT(*) as cnt
FROM health_records
GROUP BY record_type
ORDER BY cnt DESC;Date range:
SELECT MIN(start_date), MAX(start_date) FROM health_records;Table sizes:
SELECT 'health_records' as tbl, COUNT(*) FROM health_records
UNION SELECT 'workouts', COUNT(*) FROM workouts
UNION SELECT 'sleep_sessions', COUNT(*) FROM sleep_sessions
UNION SELECT 'activity_summaries', COUNT(*) FROM activity_summaries;#!/usr/bin/env python3
"""
Apple Health Database Query Tool
Query health data with multiple output formats: Markdown, JSON, FHIR R4
Usage:
python health_query.py daily [--date DATE] [--format FORMAT]
python health_query.py weekly [--weeks N] [--format FORMAT]
python health_query.py sleep [--days N] [--format FORMAT]
python health_query.py vitals [--format FORMAT]
python health_query.py activity [--days N] [--format FORMAT]
python health_query.py workouts [--days N] [--type TYPE] [--format FORMAT]
python health_query.py query "SQL" [--format FORMAT]
"""
import argparse
import json
import sqlite3
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, Dict, List, Optional
import uuid
DB_PATH = Path.home() / "data" / "health.db"
# LOINC codes for FHIR output
LOINC_CODES = {
"HKQuantityTypeIdentifierHeartRate": ("8867-4", "Heart rate", "/min"),
"HKQuantityTypeIdentifierHeartRateVariabilitySDNN": ("80404-7", "R-R interval.standard deviation", "ms"),
"HKQuantityTypeIdentifierRestingHeartRate": ("40443-4", "Resting heart rate", "/min"),
"HKQuantityTypeIdentifierOxygenSaturation": ("59408-5", "Oxygen saturation", "%"),
"HKQuantityTypeIdentifierStepCount": ("55423-8", "Number of steps", "{steps}"),
"HKQuantityTypeIdentifierBodyMass": ("29463-7", "Body weight", "kg"),
"HKQuantityTypeIdentifierRespiratoryRate": ("9279-1", "Respiratory rate", "/min"),
"HKQuantityTypeIdentifierActiveEnergyBurned": ("41981-2", "Calories burned", "kcal"),
"HKQuantityTypeIdentifierDistanceWalkingRunning": ("41953-1", "Walking distance", "km"),
"HKQuantityTypeIdentifierFlightsClimbed": ("93831-6", "Flights of stairs climbed", "{flights}"),
"HKQuantityTypeIdentifierVO2Max": ("60842-2", "VO2 max", "mL/min/kg"),
}
FRIENDLY_NAMES = {
"HKQuantityTypeIdentifierHeartRate": "Heart Rate",
"HKQuantityTypeIdentifierHeartRateVariabilitySDNN": "HRV",
"HKQuantityTypeIdentifierRestingHeartRate": "Resting HR",
"HKQuantityTypeIdentifierOxygenSaturation": "Blood Oxygen",
"HKQuantityTypeIdentifierStepCount": "Steps",
"HKQuantityTypeIdentifierActiveEnergyBurned": "Active Calories",
"HKQuantityTypeIdentifierBasalEnergyBurned": "Basal Calories",
"HKQuantityTypeIdentifierDistanceWalkingRunning": "Distance",
"HKQuantityTypeIdentifierFlightsClimbed": "Flights Climbed",
"HKQuantityTypeIdentifierAppleExerciseTime": "Exercise Minutes",
"HKQuantityTypeIdentifierVO2Max": "VO2 Max",
"HKQuantityTypeIdentifierRespiratoryRate": "Respiratory Rate",
"HKQuantityTypeIdentifierBodyMass": "Weight",
}
def get_connection() -> sqlite3.Connection:
"""Connect to health database"""
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
# ============================================================
# Deduplication Note
# ============================================================
#
# Cumulative metrics (steps, calories, distance) are recorded by BOTH
# iPhone and Apple Watch simultaneously, causing double-counting when
# both sources are summed. Solution: filter to Apple Watch data only
# using "source_name LIKE '%Watch%'" since Watch is more accurate for
# movement tracking (always on wrist).
#
# Also: start_date has timezone offset ("+0100") that SQLite's DATE()
# function doesn't handle, so we use substr(start_date, 1, 10) instead.
#
# ============================================================
# Query Functions
# ============================================================
def daily_summary(date: str = None) -> Dict[str, Any]:
"""Get daily health summary"""
if not date:
date = datetime.now().strftime("%Y-%m-%d")
conn = get_connection()
result = {"date": date, "metrics": {}}
# Steps (deduplicated - Apple Watch source only to avoid iPhone double-counting)
cursor = conn.execute("""
SELECT SUM(value) as total FROM health_records
WHERE record_type = 'HKQuantityTypeIdentifierStepCount'
AND start_date LIKE ?
AND source_name LIKE '%Watch%'
""", (f"{date}%",))
row = cursor.fetchone()
result["metrics"]["steps"] = int(row["total"]) if row["total"] else 0
# Active calories (deduplicated - Apple Watch source only)
cursor = conn.execute("""
SELECT SUM(value) as total FROM health_records
WHERE record_type = 'HKQuantityTypeIdentifierActiveEnergyBurned'
AND start_date LIKE ?
AND source_name LIKE '%Watch%'
""", (f"{date}%",))
row = cursor.fetchone()
result["metrics"]["active_calories"] = round(row["total"], 1) if row["total"] else 0
# Heart rate avg/min/max
cursor = conn.execute("""
SELECT AVG(value) as avg, MIN(value) as min, MAX(value) as max
FROM health_records
WHERE record_type = 'HKQuantityTypeIdentifierHeartRate'
AND start_date LIKE ?
AND value BETWEEN 40 AND 200
""", (f"{date}%",))
row = cursor.fetchone()
result["metrics"]["heart_rate"] = {
"avg": round(row["avg"], 1) if row["avg"] else None,
"min": int(row["min"]) if row["min"] else None,
"max": int(row["max"]) if row["max"] else None,
}
# Exercise minutes
cursor = conn.execute("""
SELECT SUM(value) as total FROM health_records
WHERE record_type = 'HKQuantityTypeIdentifierAppleExerciseTime'
AND start_date LIKE ?
""", (f"{date}%",))
row = cursor.fetchone()
result["metrics"]["exercise_minutes"] = int(row["total"]) if row["total"] else 0
# Distance (deduplicated - Apple Watch source only)
cursor = conn.execute("""
SELECT SUM(value) as total FROM health_records
WHERE record_type = 'HKQuantityTypeIdentifierDistanceWalkingRunning'
AND start_date LIKE ?
AND source_name LIKE '%Watch%'
""", (f"{date}%",))
row = cursor.fetchone()
result["metrics"]["distance_km"] = round(row["total"], 2) if row["total"] else 0
# Activity ring data
cursor = conn.execute("""
SELECT * FROM activity_summaries WHERE date = ?
""", (date,))
row = cursor.fetchone()
if row:
result["activity_rings"] = {
"move": {"value": row["active_energy_burned"], "goal": row["active_energy_goal"]},
"exercise": {"value": row["exercise_time_minutes"], "goal": row["exercise_time_goal"]},
"stand": {"value": row["stand_hours"], "goal": row["stand_hours_goal"]},
}
conn.close()
return result
def weekly_trends(weeks: int = 4) -> Dict[str, Any]:
"""Get weekly trends for key metrics"""
conn = get_connection()
end_date = datetime.now()
start_date = end_date - timedelta(weeks=weeks)
result = {"period": f"{start_date.strftime('%Y-%m-%d')} to {end_date.strftime('%Y-%m-%d')}", "weeks": []}
for w in range(weeks):
week_start = end_date - timedelta(weeks=weeks - w)
week_end = week_start + timedelta(days=7)
week_data = {
"week_of": week_start.strftime("%Y-%m-%d"),
"metrics": {}
}
# Average daily steps (deduplicated - Apple Watch source only)
# Note: Using substr(start_date,1,10) instead of DATE() because
# start_date has timezone offset (+0100) that DATE() doesn't handle
cursor = conn.execute("""
SELECT AVG(daily_steps) as avg FROM (
SELECT substr(start_date, 1, 10) as day, SUM(value) as daily_steps
FROM health_records
WHERE record_type = 'HKQuantityTypeIdentifierStepCount'
AND start_date >= ? AND start_date < ?
AND source_name LIKE '%Watch%'
GROUP BY day
)
""", (week_start.strftime("%Y-%m-%d"), week_end.strftime("%Y-%m-%d")))
row = cursor.fetchone()
week_data["metrics"]["avg_daily_steps"] = int(row["avg"]) if row["avg"] else 0
# Average resting heart rate
cursor = conn.execute("""
SELECT AVG(value) as avg FROM health_records
WHERE record_type = 'HKQuantityTypeIdentifierRestingHeartRate'
AND start_date >= ? AND start_date < ?
""", (week_start.strftime("%Y-%m-%d"), week_end.strftime("%Y-%m-%d")))
row = cursor.fetchone()
week_data["metrics"]["avg_resting_hr"] = round(row["avg"], 1) if row["avg"] else None
# Total exercise minutes
cursor = conn.execute("""
SELECT SUM(value) as total FROM health_records
WHERE record_type = 'HKQuantityTypeIdentifierAppleExerciseTime'
AND start_date >= ? AND start_date < ?
""", (week_start.strftime("%Y-%m-%d"), week_end.strftime("%Y-%m-%d")))
row = cursor.fetchone()
week_data["metrics"]["total_exercise_min"] = int(row["total"]) if row["total"] else 0
# Workout count
cursor = conn.execute("""
SELECT COUNT(*) as cnt FROM workouts
WHERE start_date >= ? AND start_date < ?
""", (week_start.strftime("%Y-%m-%d"), week_end.strftime("%Y-%m-%d")))
row = cursor.fetchone()
week_data["metrics"]["workouts"] = row["cnt"]
result["weeks"].append(week_data)
conn.close()
return result
def sleep_analysis(days: int = 7) -> Dict[str, Any]:
"""Analyze sleep patterns"""
conn = get_connection()
start_date = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
result = {"period_days": days, "nights": [], "summary": {}}
# Get sleep sessions grouped by night
cursor = conn.execute("""
SELECT SUBSTR(start_date, 1, 10) as night, sleep_stage, SUM(duration_minutes) as duration
FROM sleep_sessions
WHERE start_date >= ?
GROUP BY night, sleep_stage
ORDER BY night DESC
""", (start_date,))
nights = {}
for row in cursor:
night = row["night"]
if night not in nights:
nights[night] = {"date": night, "stages": {}}
nights[night]["stages"][row["sleep_stage"]] = round(row["duration"], 1)
# Calculate totals per night
for night, data in nights.items():
total = sum(data["stages"].values())
data["total_minutes"] = round(total, 1)
data["total_hours"] = round(total / 60, 1)
result["nights"] = list(nights.values())
# Summary stats
if nights:
all_totals = [n["total_minutes"] for n in nights.values()]
result["summary"] = {
"avg_sleep_hours": round(sum(all_totals) / len(all_totals) / 60, 1),
"nights_tracked": len(nights),
}
conn.close()
return result
def latest_vitals() -> Dict[str, Any]:
"""Get most recent vital readings"""
conn = get_connection()
vitals = ["HKQuantityTypeIdentifierHeartRate", "HKQuantityTypeIdentifierOxygenSaturation",
"HKQuantityTypeIdentifierRestingHeartRate", "HKQuantityTypeIdentifierHeartRateVariabilitySDNN",
"HKQuantityTypeIdentifierRespiratoryRate"]
result = {"timestamp": datetime.now().isoformat(), "vitals": {}}
for vital in vitals:
cursor = conn.execute("""
SELECT value, unit, start_date FROM health_records
WHERE record_type = ?
ORDER BY start_date DESC LIMIT 1
""", (vital,))
row = cursor.fetchone()
if row:
name = FRIENDLY_NAMES.get(vital, vital)
result["vitals"][name] = {
"value": round(row["value"], 1) if row["value"] else None,
"unit": row["unit"],
"recorded": row["start_date"][:19]
}
conn.close()
return result
def activity_rings(days: int = 30) -> Dict[str, Any]:
"""Get activity ring completion data"""
conn = get_connection()
start_date = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
cursor = conn.execute("""
SELECT * FROM activity_summaries
WHERE date >= ?
ORDER BY date DESC
""", (start_date,))
result = {"period_days": days, "days": [], "summary": {}}
move_pct = []
exercise_pct = []
stand_pct = []
for row in cursor:
day = {
"date": row["date"],
"move": {"value": row["active_energy_burned"], "goal": row["active_energy_goal"]},
"exercise": {"value": row["exercise_time_minutes"], "goal": row["exercise_time_goal"]},
"stand": {"value": row["stand_hours"], "goal": row["stand_hours_goal"]},
}
# Calculate percentages
if row["active_energy_goal"] and row["active_energy_goal"] > 0:
day["move"]["pct"] = round(row["active_energy_burned"] / row["active_energy_goal"] * 100, 1)
move_pct.append(day["move"]["pct"])
if row["exercise_time_goal"] and row["exercise_time_goal"] > 0:
day["exercise"]["pct"] = round(row["exercise_time_minutes"] / row["exercise_time_goal"] * 100, 1)
exercise_pct.append(day["exercise"]["pct"])
if row["stand_hours_goal"] and row["stand_hours_goal"] > 0:
day["stand"]["pct"] = round(row["stand_hours"] / row["stand_hours_goal"] * 100, 1)
stand_pct.append(day["stand"]["pct"])
result["days"].append(day)
# Summary
result["summary"] = {
"days_tracked": len(result["days"]),
"avg_move_pct": round(sum(move_pct) / len(move_pct), 1) if move_pct else 0,
"avg_exercise_pct": round(sum(exercise_pct) / len(exercise_pct), 1) if exercise_pct else 0,
"avg_stand_pct": round(sum(stand_pct) / len(stand_pct), 1) if stand_pct else 0,
"perfect_days": sum(1 for d in result["days"] if
d.get("move", {}).get("pct", 0) >= 100 and
d.get("exercise", {}).get("pct", 0) >= 100 and
d.get("stand", {}).get("pct", 0) >= 100),
}
conn.close()
return result
def workout_history(days: int = 30, workout_type: str = None) -> Dict[str, Any]:
"""Get workout history"""
conn = get_connection()
start_date = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
query = """
SELECT workout_type, duration_minutes, total_distance, distance_unit,
total_energy_burned, energy_unit, start_date, end_date, source_name
FROM workouts
WHERE start_date >= ?
"""
params = [start_date]
if workout_type:
query += " AND workout_type LIKE ?"
params.append(f"%{workout_type}%")
query += " ORDER BY start_date DESC"
cursor = conn.execute(query, params)
result = {"period_days": days, "workouts": [], "summary": {}}
total_duration = 0
total_calories = 0
types = {}
for row in cursor:
workout = {
"type": row["workout_type"].replace("HKWorkoutActivityType", ""),
"date": row["start_date"][:19],
"duration_min": round(row["duration_minutes"], 1) if row["duration_minutes"] else 0,
"calories": round(row["total_energy_burned"], 1) if row["total_energy_burned"] else 0,
}
if row["total_distance"]:
workout["distance"] = round(row["total_distance"], 2)
workout["distance_unit"] = row["distance_unit"]
result["workouts"].append(workout)
total_duration += workout["duration_min"]
total_calories += workout["calories"]
types[workout["type"]] = types.get(workout["type"], 0) + 1
result["summary"] = {
"total_workouts": len(result["workouts"]),
"total_duration_min": round(total_duration, 1),
"total_calories": round(total_calories, 1),
"by_type": types,
}
conn.close()
return result
def run_query(sql: str) -> List[Dict[str, Any]]:
"""Run custom SQL query"""
conn = get_connection()
cursor = conn.execute(sql)
columns = [desc[0] for desc in cursor.description]
result = [dict(zip(columns, row)) for row in cursor.fetchall()]
conn.close()
return result
# ============================================================
# Output Formatters
# ============================================================
def to_markdown(data: Any, title: str = "Health Data") -> str:
"""Format data as Markdown"""
lines = [f"# {title}", ""]
if isinstance(data, dict):
_dict_to_md(data, lines)
elif isinstance(data, list):
if data and isinstance(data[0], dict):
# Table format
headers = list(data[0].keys())
lines.append("| " + " | ".join(headers) + " |")
lines.append("| " + " | ".join(["---"] * len(headers)) + " |")
for row in data:
lines.append("| " + " | ".join(str(row.get(h, "")) for h in headers) + " |")
else:
for item in data:
lines.append(f"- {item}")
else:
lines.append(str(data))
return "\n".join(lines)
def _dict_to_md(d: Dict, lines: List[str], indent: int = 0):
"""Recursively convert dict to markdown"""
prefix = " " * indent
for key, value in d.items():
if isinstance(value, dict):
lines.append(f"{prefix}**{key}:**")
_dict_to_md(value, lines, indent + 1)
elif isinstance(value, list):
lines.append(f"{prefix}**{key}:** ({len(value)} items)")
if value and isinstance(value[0], dict):
# Table for list of dicts
headers = list(value[0].keys())
lines.append("")
lines.append(f"{prefix}| " + " | ".join(headers) + " |")
lines.append(f"{prefix}| " + " | ".join(["---"] * len(headers)) + " |")
for row in value[:20]: # Limit to 20 rows
lines.append(f"{prefix}| " + " | ".join(str(row.get(h, ""))[:30] for h in headers) + " |")
if len(value) > 20:
lines.append(f"{prefix}*... and {len(value) - 20} more*")
lines.append("")
else:
lines.append(f"{prefix}- **{key}:** {value}")
def to_json(data: Any) -> str:
"""Format data as JSON"""
return json.dumps(data, indent=2, default=str)
def to_ascii(data: Any, title: str = "Health Data") -> str:
"""Format data as ASCII charts and statistics"""
lines = [f"{'=' * 60}", f" {title.upper()}", f"{'=' * 60}", ""]
if isinstance(data, dict):
# Handle different data structures
if "metrics" in data:
lines.append("METRICS")
lines.append("-" * 40)
for key, value in data["metrics"].items():
if isinstance(value, dict):
if "avg" in value:
avg_val = value['avg'] if value['avg'] is not None else 'N/A'
min_val = value.get('min', 'N/A') if value.get('min') is not None else 'N/A'
max_val = value.get('max', 'N/A') if value.get('max') is not None else 'N/A'
lines.append(f" {key:20s} avg:{str(avg_val):>6} min:{str(min_val):>4} max:{str(max_val):>4}")
else:
lines.append(f" {key:20s} {value.get('value', 'N/A')}")
else:
lines.append(f" {key:20s} {value:>10}")
lines.append("")
if "activity_rings" in data:
lines.append("ACTIVITY RINGS")
lines.append("-" * 40)
for ring, info in data["activity_rings"].items():
val = info.get("value", 0)
goal = info.get("goal", 1)
pct = min(val / goal * 100, 100) if goal else 0
bar_len = int(pct / 5) # 20 chars = 100%
bar = "█" * bar_len + "░" * (20 - bar_len)
lines.append(f" {ring:10s} [{bar}] {pct:5.1f}% ({val:.0f}/{goal:.0f})")
lines.append("")
if "vitals" in data:
lines.append("VITALS")
lines.append("-" * 40)
for name, info in data["vitals"].items():
val = info.get("value", "N/A")
unit = info.get("unit", "")
lines.append(f" {name:20s} {val:>8} {unit}")
lines.append("")
if "weeks" in data:
lines.append("WEEKLY TRENDS")
lines.append("-" * 40)
# Steps bar chart
weeks = data["weeks"]
if weeks:
max_steps = max(w["metrics"].get("avg_daily_steps", 0) for w in weeks) or 1
lines.append(" Avg Daily Steps:")
for w in weeks:
steps = w["metrics"].get("avg_daily_steps", 0)
bar_len = int(steps / max_steps * 30)
bar = "▓" * bar_len
lines.append(f" {w['week_of'][5:10]} {bar} {steps:,}")
lines.append("")
# Exercise minutes
lines.append(" Exercise Minutes:")
max_ex = max(w["metrics"].get("total_exercise_min", 0) for w in weeks) or 1
for w in weeks:
ex = w["metrics"].get("total_exercise_min", 0)
bar_len = int(ex / max_ex * 30)
bar = "▓" * bar_len
lines.append(f" {w['week_of'][5:10]} {bar} {ex}")
lines.append("")
if "nights" in data:
lines.append("SLEEP ANALYSIS")
lines.append("-" * 40)
nights = data.get("nights", [])[:10] # Last 10 nights
if nights:
for n in nights:
hours = n.get("total_hours", 0) or 0
bar_len = int(min(hours, 10) * 3) # 30 chars = 10 hours
bar = "█" * bar_len
stages = n.get("stages", {})
deep = stages.get("Deep", 0) or 0
rem = stages.get("REM", 0) or 0
date_str = (n.get("date") or "????-??-??")[5:10]
lines.append(f" {date_str} [{bar:30s}] {hours:.1f}h (D:{deep:.0f}m R:{rem:.0f}m)")
if "summary" in data:
lines.append("")
lines.append(f" Average: {data['summary'].get('avg_sleep_hours', 0):.1f} hours/night")
lines.append("")
if "days" in data and "summary" in data:
# Activity summary
s = data["summary"]
lines.append("SUMMARY")
lines.append("-" * 40)
lines.append(f" Days tracked: {s.get('days_tracked', 0)}")
if "avg_move_pct" in s:
lines.append(f" Avg Move %: {s.get('avg_move_pct', 0):.1f}%")
lines.append(f" Avg Exercise %: {s.get('avg_exercise_pct', 0):.1f}%")
lines.append(f" Avg Stand %: {s.get('avg_stand_pct', 0):.1f}%")
lines.append(f" Perfect days: {s.get('perfect_days', 0)}")
lines.append("")
if "workouts" in data:
lines.append("WORKOUTS")
lines.append("-" * 40)
workouts = data.get("workouts", [])[:10]
for w in workouts:
dur = w.get("duration_min", 0)
cal = w.get("calories", 0)
dist = w.get("distance", "")
dist_str = f"{dist:.1f}km" if dist else ""
lines.append(f" {w['date'][5:16]} {w['type']:15s} {dur:5.0f}min {cal:5.0f}cal {dist_str}")
if "summary" in data:
s = data["summary"]
lines.append("")
lines.append(f" Total: {s.get('total_workouts', 0)} workouts, {s.get('total_duration_min', 0):.0f} min, {s.get('total_calories', 0):.0f} cal")
lines.append("")
elif isinstance(data, list):
# Table format for query results
if data and isinstance(data[0], dict):
headers = list(data[0].keys())
col_widths = {h: max(len(h), max(len(str(row.get(h, ""))[:20]) for row in data)) for h in headers}
header_line = " | ".join(h.ljust(col_widths[h]) for h in headers)
lines.append(header_line)
lines.append("-" * len(header_line))
for row in data[:30]:
lines.append(" | ".join(str(row.get(h, ""))[:20].ljust(col_widths[h]) for h in headers))
if len(data) > 30:
lines.append(f"... and {len(data) - 30} more rows")
lines.append("=" * 60)
return "\n".join(lines)
def to_fhir(data: Any, resource_type: str = "Observation") -> str:
"""Format data as FHIR R4 Bundle"""
bundle = {
"resourceType": "Bundle",
"id": str(uuid.uuid4()),
"type": "collection",
"timestamp": datetime.now().isoformat() + "Z",
"entry": []
}
# Convert data to FHIR observations
observations = _data_to_fhir_observations(data)
for obs in observations:
bundle["entry"].append({
"fullUrl": f"urn:uuid:{obs['id']}",
"resource": obs
})
return json.dumps(bundle, indent=2)
def _data_to_fhir_observations(data: Any) -> List[Dict]:
"""Convert health data to FHIR Observations"""
observations = []
if isinstance(data, dict):
# Handle different data structures
if "vitals" in data:
for name, info in data["vitals"].items():
obs = _create_fhir_observation(name, info.get("value"), info.get("unit"), info.get("recorded"))
if obs:
observations.append(obs)
elif "metrics" in data:
for name, value in data["metrics"].items():
if isinstance(value, dict):
obs = _create_fhir_observation(name, value.get("avg") or value.get("value"), None, data.get("date"))
else:
obs = _create_fhir_observation(name, value, None, data.get("date"))
if obs:
observations.append(obs)
return observations
def _create_fhir_observation(metric_name: str, value: Any, unit: str = None, timestamp: str = None) -> Optional[Dict]:
"""Create a single FHIR Observation resource"""
if value is None:
return None
# Find LOINC code
loinc_code = None
loinc_display = metric_name
ucum_unit = unit or ""
for hk_type, (code, display, ucum) in LOINC_CODES.items():
if metric_name.lower() in display.lower() or metric_name.lower() in hk_type.lower():
loinc_code = code
loinc_display = display
ucum_unit = ucum
break
obs = {
"resourceType": "Observation",
"id": str(uuid.uuid4()),
"status": "final",
"category": [{
"coding": [{
"system": "http://terminology.hl7.org/CodeSystem/observation-category",
"code": "vital-signs",
"display": "Vital Signs"
}]
}],
"code": {
"coding": [{
"system": "http://loinc.org",
"code": loinc_code or "unknown",
"display": loinc_display
}],
"text": metric_name
},
"effectiveDateTime": timestamp or datetime.now().isoformat(),
"valueQuantity": {
"value": value,
"unit": ucum_unit,
"system": "http://unitsofmeasure.org",
"code": ucum_unit
}
}
return obs
# ============================================================
# CLI
# ============================================================
def main():
parser = argparse.ArgumentParser(description="Query Apple Health database")
parser.add_argument("--format", "-f", choices=["markdown", "json", "fhir", "ascii"], default="markdown",
help="Output format")
subparsers = parser.add_subparsers(dest="command", help="Query command")
# Daily summary
daily_p = subparsers.add_parser("daily", help="Daily health summary")
daily_p.add_argument("--date", "-d", help="Date (YYYY-MM-DD), default: today")
# Weekly trends
weekly_p = subparsers.add_parser("weekly", help="Weekly trends")
weekly_p.add_argument("--weeks", "-w", type=int, default=4, help="Number of weeks")
# Sleep analysis
sleep_p = subparsers.add_parser("sleep", help="Sleep analysis")
sleep_p.add_argument("--days", "-d", type=int, default=7, help="Number of days")
# Latest vitals
subparsers.add_parser("vitals", help="Latest vital readings")
# Activity rings
activity_p = subparsers.add_parser("activity", help="Activity ring data")
activity_p.add_argument("--days", "-d", type=int, default=30, help="Number of days")
# Workouts
workouts_p = subparsers.add_parser("workouts", help="Workout history")
workouts_p.add_argument("--days", "-d", type=int, default=30, help="Number of days")
workouts_p.add_argument("--type", "-t", help="Filter by workout type")
# Custom query
query_p = subparsers.add_parser("query", help="Run custom SQL query")
query_p.add_argument("sql", help="SQL query")
args = parser.parse_args()
# Execute command
if args.command == "daily":
data = daily_summary(args.date)
title = f"Daily Summary - {data['date']}"
elif args.command == "weekly":
data = weekly_trends(args.weeks)
title = f"Weekly Trends ({args.weeks} weeks)"
elif args.command == "sleep":
data = sleep_analysis(args.days)
title = f"Sleep Analysis ({args.days} days)"
elif args.command == "vitals":
data = latest_vitals()
title = "Latest Vitals"
elif args.command == "activity":
data = activity_rings(args.days)
title = f"Activity Rings ({args.days} days)"
elif args.command == "workouts":
data = workout_history(args.days, args.type)
title = f"Workout History ({args.days} days)"
elif args.command == "query":
data = run_query(args.sql)
title = "Query Results"
else:
parser.print_help()
return
# Format output
if args.format == "markdown":
print(to_markdown(data, title))
elif args.format == "json":
print(to_json(data))
elif args.format == "fhir":
print(to_fhir(data))
elif args.format == "ascii":
print(to_ascii(data, title))
if __name__ == "__main__":
main()
Related skills
How it compares
Pick health-data over generic SQL skills when you need Apple Health schema knowledge, pre-built vitals/sleep queries, and FHIR R4 LOINC export out of the box.
FAQ
What output formats does health-data support?
health-data supports four output formats via health_query.py: Markdown (default), JSON, FHIR R4 Bundles with LOINC-coded Observations, and ASCII Unicode bar charts for visualization.
How many health metrics does health-data cover?
health-data queries a local Apple Health SQLite database with 6.3M+ records spanning 43 health metric types including vitals, sleep, activity rings, and workouts.