Mapping — Hevy
Source: Hevy CSV export (one row per set). Pillar: B (strength). Mapper:
mapHevy —
mapHevy(csv) → { records, warnings }, one Session per workout.
Structural correspondence
| Hevy CSV | OpenBody |
|---|---|
a workout (rows sharing title + start_time) | Session (disciplines: ["strength"], name ← title, notes ← description, clientRecordId ← the natural key) |
consecutive rows for one exercise_title | Exercise (exerciseRef: canonical registry id where the crosswalk resolves the title, plus the lossless opaque original) |
| one set row | WorkUnit (scoring from which columns are present) |
set_type (normal|warmup|drop|failure) | WorkUnit.setRole (working|warmup|drop|failure) |
weight_kg | performance.load (unit: "kg") |
reps / distance_km / duration_seconds | performance.reps / .distance / .time |
rpe | performance.effortLoad ({ kind: "internal", method: "RPE", value }) |
superset_id (non-empty) | a Block with grouping: "superset" |
Input (one set)
"title","start_time","end_time",...,"exercise_title","superset_id",...,"set_type","weight_kg","reps",...,"rpe""Morning workout","22 Dec 2025, 08:00","22 Dec 2025, 08:37",...,"Pull Up (Assisted)",,...,"normal",21,10,,0,8.5Output (OpenBody wire record)
{ "id": "hevy-sess-62f09e1f", "recordType": "Session", "subject": "subj-001", "clientRecordId": "Morning workout|22 Dec 2025, 08:00", "name": "Morning workout", "disciplines": ["strength"], "startTime": "2025-12-22T08:00:00Z", "endTime": "2025-12-22T08:37:00Z", "exercises": [ { "id": "hevy-sess-62f09e1f-ex0", "recordType": "Exercise", "exerciseRef": { "opaque": "Pull Up (Assisted)" }, "workUnits": [ { "id": "hevy-sess-62f09e1f-ex0-set0", "recordType": "WorkUnit", "scoring": "reps", "setRole": "working", "performance": { "reps": 10, "load": { "value": 21, "unit": "kg", "basis": "assist" }, "effortLoad": [{ "kind": "internal", "method": "RPE", "value": 8.5 }] } } ] } ]}Notes & edge cases
- Stable ids. The export has no workout id of its own, so the natural key
(
title|start_time) becomesclientRecordId(§7.1) and a content hash of it the record id — positional numbering would renumber everything when one more workout is exported, defeating dedup. - Workout title maps to the first-class
name(added in v0.3); the first Hevy mapper silently dropped it — a real losslessness bug that dogfooding surfaced. - Supersets force all-blocks. A
Sessioncarries at most one ofblocks|exercises|workUnits(§5.3). If any row has asuperset_id, the mapper emits everything underblocks[], wrapping standalone exercises in singletonBlocks and grouping superset mates in aBlockwithgrouping: "superset". - Scoring is chosen per set:
repsif present, elsedistance, elsetime— so plank and cardio rows map cleanly alongside strength sets. - Exercise identity: names that the registry crosswalk covers
resolve to canonical ids alongside the
opaqueoriginal (the fixture’s"Leg Press (Machine)"becomes{ "id": "leg-press.machine", "opaque": "Leg Press (Machine)" }); anything unresolved — like"Pull Up (Assisted)"above — keeps theopaquefloor. Lossless either way, per the matching ladder.
Body measurements (measurement_data.csv)
Hevy also exports a separate measurement_data.csv of body metrics logged over time
(one row per date; blank cells for metrics not recorded that day). The
mapHevyMeasurements
mapper — mapHevyMeasurements(csv) → { records, warnings } — turns each non-empty cell into a
point-in-time Measurement (Pillar A), and the converter detects and
reads it automatically.
| Column | OpenBody Measurement.type | Unit |
|---|---|---|
weight_kg | body_mass (canonical) | kg |
fat_percent | body_fat_percentage (canonical) | % |
<stem>_in circumferences (neck_in, chest_in, left_bicep_in, …) | side-agnostic anthropometry token (neck_circumference, chest_circumference, bicep_circumference, …) + laterality | [in_i] |
<stem>_cm circumferences (neck_cm, chest_cm, left_bicep_cm, …) | same side-agnostic anthropometry token + laterality | cm |
Every Hevy metric resolves to a canonical registry type — weight and body-fat to the
body-composition subset, and body circumferences to the anthropometry subset
(neck_circumference, waist_circumference, bicep_circumference, …). The
Measurement type comes from the column stem and the unit from the suffix: Hevy
names circumference columns by the user’s chosen length unit, so <stem>_in → [in_i]
(international inch) and <stem>_cm → cm. (Weight is always kg and body-fat always a
unitless percent.) This fixes a real bug — the earlier mapper hardcoded the _in columns, so
a metric-unit user’s circumferences matched nothing and were silently dropped. A limb girth’s
side comes from the column’s left_/right_ prefix and is carried on the closed
Measurement.laterality field (§4.1, left|right|bilateral) — so left_bicep_cm maps to
bicep_circumference + laterality: "left"; the type token names the girth only, and
non-lateral girths (neck/waist/…) omit laterality. Any header column that is neither date nor a recognized metric
raises a one-time unrecognized-column warning, so future Hevy format drift surfaces instead
of dropping silently. Each measurement is point-in-time (startTime == endTime), and the
offset-less date is stamped with the same utcOffset default ("Z") as the workout export.