Skip to content

Mapping — Hevy

Source: Hevy CSV export (one row per set). Pillar: B (strength). Mapper: mapHevymapHevy(csv) → { records, warnings }, one Session per workout.

Structural correspondence

Hevy CSVOpenBody
a workout (rows sharing title + start_time)Session (disciplines: ["strength"], nametitle, notesdescription, clientRecordId ← the natural key)
consecutive rows for one exercise_titleExercise (exerciseRef: canonical registry id where the crosswalk resolves the title, plus the lossless opaque original)
one set rowWorkUnit (scoring from which columns are present)
set_type (normal|warmup|drop|failure)WorkUnit.setRole (working|warmup|drop|failure)
weight_kgperformance.load (unit: "kg")
reps / distance_km / duration_secondsperformance.reps / .distance / .time
rpeperformance.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.5

Output (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) becomes clientRecordId (§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 Session carries at most one of blocks|exercises|workUnits (§5.3). If any row has a superset_id, the mapper emits everything under blocks[], wrapping standalone exercises in singleton Blocks and grouping superset mates in a Block with grouping: "superset".
  • Scoring is chosen per set: reps if present, else distance, else time — so plank and cardio rows map cleanly alongside strength sets.
  • Exercise identity: names that the registry crosswalk covers resolve to canonical ids alongside the opaque original (the fixture’s "Leg Press (Machine)" becomes { "id": "leg-press.machine", "opaque": "Leg Press (Machine)" }); anything unresolved — like "Pull Up (Assisted)" above — keeps the opaque floor. 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.

ColumnOpenBody Measurement.typeUnit
weight_kgbody_mass (canonical)kg
fat_percentbody_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 + lateralitycm

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>_cmcm. (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.