Skip to content

Commit 8dc8ea8

Browse files
committed
Address XML workout import review feedback
1 parent 58c343b commit 8dc8ea8

7 files changed

Lines changed: 83 additions & 23 deletions

File tree

cmd/import/main.go

Lines changed: 1 addition & 1 deletion
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()

docs/ai-plans/2026-07-01-apple-health-xml-workout-import.html

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ <h3>Storage is ready</h3>
133133
<section class="ok">
134134
<h2>Real Export Findings</h2>
135135
<p>
136-
The unpacked export at <code>/Users/dzarlax/Downloads/apple_health_export/</code> was inspected on 2026-07-01.
136+
A local unpacked Apple Health export at <code>&lt;LOCAL_APPLE_HEALTH_EXPORT_DIR&gt;/</code> was inspected on 2026-07-01.
137137
Only structural counts and attribute shapes were used; implementation should not bake in personal paths or source names.
138138
</p>
139139
<ul>
@@ -182,7 +182,7 @@ <h2>Proposed Design</h2>
182182
<h2>Workout Field Mapping</h2>
183183
<ul>
184184
<li><code>external_id</code>: prefer <code>HKMetadataKeySyncIdentifier</code>, then <code>HKExternalUUID</code>; fall back to a deterministic hash from type/start/end/source/duration/statistics when Apple exports lack IDs.</li>
185-
<li><code>name</code>: map <code>workoutActivityType</code> suffix into readable names such as <code>Outdoor Run</code>, <code>Outdoor Cycle</code>, <code>Walking</code>. Unknown values keep a readable suffix rather than failing import.</li>
185+
<li><code>name</code>: map <code>workoutActivityType</code> suffix into readable names such as <code>Outdoor Run</code>, <code>Outdoor Cycling</code>, <code>Walking</code>. Unknown values keep a readable suffix rather than failing import.</li>
186186
<li><code>start_time</code>, <code>end_time</code>: parse Apple Health XML timestamps with the existing HealthKit layout.</li>
187187
<li><code>duration_sec</code>: use <code>duration</code> plus <code>durationUnit</code> when present; otherwise compute from start/end.</li>
188188
<li><code>energy_kcal</code>: parse nested <code>WorkoutStatistics type="HKQuantityTypeIdentifierActiveEnergyBurned"</code> using <code>sum</code> and <code>unit</code>.</li>
@@ -309,7 +309,7 @@ <h2>Verification Run</h2>
309309
<li><code>env GOCACHE=/tmp/health-go-build-cache go test ./internal/applehealth -count=1</code> passed.</li>
310310
<li><code>env GOCACHE=/tmp/health-go-build-cache go test ./internal/applehealth ./internal/health -count=1</code> passed.</li>
311311
<li><code>env GOCACHE=/tmp/health-go-build-cache go test ./cmd/import ./internal/handler ./internal/storage ./internal/ui -run "Workout|AppleHealth|ParseXML|Import" -count=1</code> passed.</li>
312-
<li><code>env GOCACHE=/tmp/health-go-build-cache go run ./cmd/import --file /Users/dzarlax/Downloads/apple_health_export/export.xml --dry-run --batch 100000 --pause 0</code> parsed <code>6,803,675</code> metric points and <code>862</code> workouts in 24 seconds, with no DB writes.</li>
312+
<li><code>env GOCACHE=/tmp/health-go-build-cache go run ./cmd/import --file /path/to/apple_health_export/export.xml --dry-run --batch 100000 --pause 0</code> parsed <code>6,803,618</code> metric points and <code>862</code> workouts in 24 seconds, with no DB writes.</li>
313313
<li><code>env GOCACHE=/tmp/health-go-build-cache make test-unit</code> passed.</li>
314314
</ul>
315315
</section>
@@ -324,6 +324,21 @@ <h2>Known Limitations After Implementation</h2>
324324
</ul>
325325
</section>
326326

327+
<section class="ok">
328+
<h2>PR Review Follow-Up</h2>
329+
<p>
330+
Addressed review feedback on 2026-07-03 after PR creation.
331+
</p>
332+
<ul>
333+
<li>Applied the existing physiological quality gate to mapped XML quantity records, so impossible zero values such as heart rate are dropped while valid zero values such as walking asymmetry are preserved.</li>
334+
<li>Mapped XML cycling workouts to canonical <code>Indoor Cycling</code> / <code>Outdoor Cycling</code> names to match existing exact-name filters.</li>
335+
<li>Changed Admin import status counter reads to <code>atomic.LoadInt64</code> to match atomic writers.</li>
336+
<li>Redacted local machine paths from this plan and kept dry-run examples portable.</li>
337+
<li>Added validation/canonical-name tests for <code>parseWorkout</code> and kept duration-minute handling explicit.</li>
338+
<li>Cleaned up small review nits: batch flag help text and standard-library <code>math.Abs</code> in unit tests.</li>
339+
</ul>
340+
</section>
341+
327342
<section class="approval">
328343
<h2>Approval Gate</h2>
329344
<p>

internal/applehealth/parse.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -453,6 +453,8 @@ func workoutDurationSec(raw, unit string, start, end time.Time) float64 {
453453
return v
454454
case "h", "hr", "hrs", "hour", "hours":
455455
return v * 3600
456+
case "m", "min", "mins", "minute", "minutes":
457+
return v * 60
456458
default:
457459
return v * 60
458460
}
@@ -470,9 +472,9 @@ func workoutName(activityType string, indoor bool) string {
470472
return "Outdoor Run"
471473
case "Cycling":
472474
if indoor {
473-
return "Indoor Cycle"
475+
return "Indoor Cycling"
474476
}
475-
return "Outdoor Cycle"
477+
return "Outdoor Cycling"
476478
case "Walking":
477479
return "Walking"
478480
case "FunctionalStrengthTraining":
@@ -600,6 +602,9 @@ func parseRecord(a map[string]string) []storage.MetricPoint {
600602
if hkFractionToPercent[suffix] && value > 0 && value <= 1.0 {
601603
value *= 100
602604
}
605+
if health.IsImpossible(info[0], value) {
606+
return nil
607+
}
603608
return []storage.MetricPoint{
604609
{MetricName: info[0], Units: units, Date: startDate, Qty: value, Source: source},
605610
}

internal/applehealth/parse_test.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ func TestParseXMLFocusedEdgeFixturePinsImportSafety(t *testing.T) {
9595
}
9696
assertPoint(t, points, "blood_oxygen_saturation", "%", "2026-02-01 06:00:00 +0100", "Synthetic Watch", 98)
9797
assertPoint(t, points, "walking_asymmetry", "%", "2026-02-01 06:05:00 +0100", "Synthetic Watch", 0)
98+
assertNoPoint(t, points, "heart_rate", "2026-02-01 06:10:00 +0100")
9899
assertPoint(t, points, "mindful_minutes", "min", "2026-02-01 09:00:00 +0100", "Synthetic Phone", 15)
99100
assertPoint(t, points, "apple_stand_hour", "count", "2026-02-01 10:00:00 +0100", "Synthetic Watch", 1)
100101
assertPoint(t, points, "some_new_metric", "count", "2026-02-01 13:00:00 +0100", "Synthetic Device", 12.5)
@@ -103,6 +104,48 @@ func TestParseXMLFocusedEdgeFixturePinsImportSafety(t *testing.T) {
103104
assertNoMetric(t, points, "blood_pressure")
104105
}
105106

107+
func TestParseWorkoutValidationAndCanonicalNames(t *testing.T) {
108+
if _, ok := parseWorkout(xmlWorkout{}); ok {
109+
t.Fatal("empty workout parsed, want rejected")
110+
}
111+
if _, ok := parseWorkout(xmlWorkout{
112+
ActivityType: "HKWorkoutActivityTypeRunning",
113+
StartDate: "2026-01-02 12:45:00 +0000",
114+
EndDate: "2026-01-02 12:00:00 +0000",
115+
}); ok {
116+
t.Fatal("workout with end before start parsed, want rejected")
117+
}
118+
119+
indoor, ok := parseWorkout(xmlWorkout{
120+
ActivityType: "HKWorkoutActivityTypeCycling",
121+
Duration: "30",
122+
DurationUnit: "min",
123+
StartDate: "2026-01-02 12:00:00 +0000",
124+
EndDate: "2026-01-02 12:30:00 +0000",
125+
Metadata: []xmlMetadataEntry{{Key: "HKIndoorWorkout", Value: "1"}},
126+
})
127+
if !ok {
128+
t.Fatal("valid indoor cycling workout rejected")
129+
}
130+
if indoor.Name != "Indoor Cycling" {
131+
t.Fatalf("indoor cycling name = %q, want Indoor Cycling", indoor.Name)
132+
}
133+
134+
outdoor, ok := parseWorkout(xmlWorkout{
135+
ActivityType: "HKWorkoutActivityTypeCycling",
136+
Duration: "30",
137+
DurationUnit: "min",
138+
StartDate: "2026-01-02 12:00:00 +0000",
139+
EndDate: "2026-01-02 12:30:00 +0000",
140+
})
141+
if !ok {
142+
t.Fatal("valid outdoor cycling workout rejected")
143+
}
144+
if outdoor.Name != "Outdoor Cycling" {
145+
t.Fatalf("outdoor cycling name = %q, want Outdoor Cycling", outdoor.Name)
146+
}
147+
}
148+
106149
func TestParseXMLEmptyAndMalformedInputs(t *testing.T) {
107150
var emitted bool
108151
if err := parseXMLFixture("testdata/empty_export.xml", func(points []storage.MetricPoint) {

internal/applehealth/testdata/focused_edge_export.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
<HealthData locale="en_US">
33
<Record type="HKQuantityTypeIdentifierOxygenSaturation" sourceName="Synthetic Watch" unit="%" creationDate="2026-02-01 06:00:00 +0100" startDate="2026-02-01 06:00:00 +0100" endDate="2026-02-01 06:01:00 +0100" value="98"/>
44
<Record type="HKQuantityTypeIdentifierWalkingAsymmetryPercentage" sourceName="Synthetic Watch" unit="%" creationDate="2026-02-01 06:05:00 +0100" startDate="2026-02-01 06:05:00 +0100" endDate="2026-02-01 06:06:00 +0100" value="0"/>
5+
<Record type="HKQuantityTypeIdentifierHeartRate" sourceName="Synthetic Watch" unit="count/min" creationDate="2026-02-01 06:10:00 +0100" startDate="2026-02-01 06:10:00 +0100" endDate="2026-02-01 06:11:00 +0100" value="0"/>
56
<Record type="HKCategoryTypeIdentifierMindfulSession" sourceName="Synthetic Phone" creationDate="2026-02-01 09:15:00 +0100" startDate="2026-02-01 09:00:00 +0100" endDate="2026-02-01 09:15:00 +0100" value="HKCategoryValueNotApplicable"/>
67
<Record type="HKCategoryTypeIdentifierAppleStandHour" sourceName="Synthetic Watch" creationDate="2026-02-01 10:00:00 +0100" startDate="2026-02-01 10:00:00 +0100" endDate="2026-02-01 11:00:00 +0100" value="HKCategoryValueAppleStandHourStood"/>
78
<Record type="HKCategoryTypeIdentifierMindfulSession" sourceName="Synthetic Phone" creationDate="2026-02-01 11:00:00 +0100" startDate="2026-02-01 11:00:00 +0100" endDate="2026-02-01 10:59:00 +0100" value="HKCategoryValueNotApplicable"/>
Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package health
22

3-
import "testing"
3+
import (
4+
"math"
5+
"testing"
6+
)
47

58
func TestNormalizeDistanceKm(t *testing.T) {
69
tests := []struct {
@@ -17,16 +20,9 @@ func TestNormalizeDistanceKm(t *testing.T) {
1720

1821
for _, tt := range tests {
1922
t.Run(tt.name, func(t *testing.T) {
20-
if got := NormalizeDistanceKm(tt.qty, tt.units); abs(got-tt.want) > 0.000001 {
23+
if got := NormalizeDistanceKm(tt.qty, tt.units); math.Abs(got-tt.want) > 0.000001 {
2124
t.Fatalf("NormalizeDistanceKm(%v, %q) = %v, want %v", tt.qty, tt.units, got, tt.want)
2225
}
2326
})
2427
}
2528
}
26-
27-
func abs(v float64) float64 {
28-
if v < 0 {
29-
return -v
30-
}
31-
return v
32-
}

internal/ui/import_handler.go

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -41,14 +41,14 @@ func (j *importJob) status() importStatus {
4141
s := importStatus{
4242
Running: j.running,
4343
Done: j.done,
44-
Parsed: j.parsed,
45-
Inserted: j.inserted,
46-
Skipped: j.skipped,
47-
WorkoutsParsed: j.workoutsParsed,
48-
WorkoutsUpserted: j.workoutsUpserted,
49-
WorkoutsFailed: j.workoutsFailed,
50-
BytesRead: j.bytesRead,
51-
TotalBytes: j.totalBytes,
44+
Parsed: atomic.LoadInt64(&j.parsed),
45+
Inserted: atomic.LoadInt64(&j.inserted),
46+
Skipped: atomic.LoadInt64(&j.skipped),
47+
WorkoutsParsed: atomic.LoadInt64(&j.workoutsParsed),
48+
WorkoutsUpserted: atomic.LoadInt64(&j.workoutsUpserted),
49+
WorkoutsFailed: atomic.LoadInt64(&j.workoutsFailed),
50+
BytesRead: atomic.LoadInt64(&j.bytesRead),
51+
TotalBytes: atomic.LoadInt64(&j.totalBytes),
5252
StartedAt: j.startedAt.Format(time.RFC3339),
5353
Err: j.err,
5454
}

0 commit comments

Comments
 (0)