Skip to content

Commit 03585ba

Browse files
authored
Merge pull request #194 from Dzarlax-AI/apple-health-xml-workout-import
[codex] Import Apple Health XML workouts
2 parents f3cd4d2 + 8dc8ea8 commit 03585ba

10 files changed

Lines changed: 1045 additions & 134 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,8 @@ The endpoint stores summary fields only (duration, distance, energy, average / m
225225

226226
If `HEALTH_HR_ZONES_BPM` is configured, the ingest path also computes time-in-zone (Z1..Z5) from the per-minute HR samples in the payload and stores five integer columns per workout. Pick zone borders that match your physiology — the Karvonen (HR Reserve) method using observed MaxHR and resting HR is more accurate than the textbook `220 - age` formula.
227227

228+
Apple Health XML imports (`cmd/import` and the Admin import UI) also ingest `<Workout>` summaries into the same `workouts` table. XML exports provide nested `WorkoutStatistics` for distance, energy, and aggregate heart-rate fields on some workouts, so imported rows may include `distance_km`, `energy_kcal`, `avg_hr_bpm`, and `max_hr_bpm`. GPX workout routes are deliberately not imported.
229+
228230
The `/health` endpoint (no suffix) still accepts all metrics unfiltered for backward compatibility.
229231

230232
> **Why two automations?** HealthKit redistributes cumulative metrics (steps, calories) across time buckets with fractional values. With minute-level grouping, the sum of these fractions exceeds the actual total by ~20-30%. Hourly grouping uses `HKStatisticsQuery` which returns correctly deduplicated totals. Instantaneous metrics (heart rate, SpO2) don't have this problem -- they are averaged, not summed -- so minute-level grouping preserves useful granularity.

cmd/import/main.go

Lines changed: 58 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import (
2323

2424
func main() {
2525
filePath := flag.String("file", "", "Apple Health export (.zip or export.xml) — required")
26-
batchSize := flag.Int("batch", 500, "metric points per DB transaction")
26+
batchSize := flag.Int("batch", 500, "metric points or workouts per DB transaction")
2727
pauseDur := flag.Duration("pause", 150*time.Millisecond, "sleep between batches (rate-limits DB load)")
2828
dryRun := flag.Bool("dry-run", false, "parse only — do not write to DB")
2929
flag.Parse()
@@ -47,11 +47,16 @@ func main() {
4747
}
4848

4949
var (
50-
totalParsed int
51-
totalInserted int
52-
batchN int
53-
pending []storage.MetricPoint
54-
startTime = time.Now()
50+
totalParsed int
51+
totalInserted int
52+
totalWorkoutsParsed int
53+
totalWorkoutsUpsert int
54+
totalWorkoutsFailed int
55+
batchN int
56+
workoutBatchN int
57+
pending []storage.MetricPoint
58+
pendingWorkouts []storage.Workout
59+
startTime = time.Now()
5560
)
5661

5762
flush := func(pts []storage.MetricPoint) {
@@ -81,6 +86,36 @@ func main() {
8186
}
8287
}
8388

89+
flushWorkouts := func(workouts []storage.Workout) {
90+
workoutBatchN++
91+
totalWorkoutsParsed += len(workouts)
92+
93+
if *dryRun {
94+
if totalWorkoutsParsed%100 == 0 || totalWorkoutsParsed < 20 {
95+
log.Printf("[dry-run] parsed %d workouts so far…", totalWorkoutsParsed)
96+
}
97+
return
98+
}
99+
100+
var failed int
101+
for _, w := range workouts {
102+
if err := db.UpsertWorkout(0, w); err != nil {
103+
failed++
104+
log.Printf("workout batch %d upsert %s error: %v", workoutBatchN, w.ExternalID, err)
105+
}
106+
}
107+
totalWorkoutsFailed += failed
108+
totalWorkoutsUpsert += len(workouts) - failed
109+
110+
elapsed := time.Since(startTime).Round(time.Second)
111+
log.Printf("workout batch %d: %d parsed / %d upserted / %d failed (total %d upserted, %s elapsed)",
112+
workoutBatchN, len(workouts), len(workouts)-failed, failed, totalWorkoutsUpsert, elapsed)
113+
114+
if *pauseDur > 0 {
115+
time.Sleep(*pauseDur)
116+
}
117+
}
118+
84119
// Collect points up to batchSize, then flush.
85120
emit := func(pts []storage.MetricPoint) {
86121
pending = append(pending, pts...)
@@ -90,31 +125,43 @@ func main() {
90125
}
91126
}
92127

128+
emitWorkouts := func(workouts []storage.Workout) {
129+
pendingWorkouts = append(pendingWorkouts, workouts...)
130+
for len(pendingWorkouts) >= *batchSize {
131+
flushWorkouts(pendingWorkouts[:*batchSize])
132+
pendingWorkouts = pendingWorkouts[*batchSize:]
133+
}
134+
}
135+
93136
log.Printf("starting import from %s (batch=%d pause=%s dry-run=%v)",
94137
*filePath, *batchSize, *pauseDur, *dryRun)
95138

139+
opts := applehealth.EmitOptions{Points: emit, Workouts: emitWorkouts}
96140
var parseErr error
97141
switch {
98142
case len(*filePath) > 4 && (*filePath)[len(*filePath)-4:] == ".zip":
99-
parseErr = applehealth.ParseZip(*filePath, emit, nil)
143+
parseErr = applehealth.ParseZipWithOptions(*filePath, opts, nil)
100144
default:
101-
parseErr = applehealth.ParseXMLFile(*filePath, emit, nil)
145+
parseErr = applehealth.ParseXMLFileWithOptions(*filePath, opts, nil)
102146
}
103147
if len(pending) > 0 {
104148
flush(pending)
105149
}
150+
if len(pendingWorkouts) > 0 {
151+
flushWorkouts(pendingWorkouts)
152+
}
106153

107154
if parseErr != nil {
108155
log.Printf("parse error (partial import may have succeeded): %v", parseErr)
109156
}
110157

111158
elapsed := time.Since(startTime).Round(time.Second)
112159
if *dryRun {
113-
log.Printf("dry-run complete: %d points parsed in %s", totalParsed, elapsed)
160+
log.Printf("dry-run complete: %d points and %d workouts parsed in %s", totalParsed, totalWorkoutsParsed, elapsed)
114161
return
115162
}
116-
log.Printf("import complete: %d points parsed, %d inserted, %d duplicates skipped in %s",
117-
totalParsed, totalInserted, totalParsed-totalInserted, elapsed)
163+
log.Printf("import complete: %d points parsed, %d inserted, %d duplicates skipped; %d workouts parsed, %d upserted, %d failed in %s",
164+
totalParsed, totalInserted, totalParsed-totalInserted, totalWorkoutsParsed, totalWorkoutsUpsert, totalWorkoutsFailed, elapsed)
118165

119166
// Trigger a full backfill so daily_scores and caches are up to date.
120167
log.Println("running backfill (this may take a few minutes)…")

0 commit comments

Comments
 (0)