Skip to content

Mapping — OpenBody → Strong (outbound)

Direction: outbound — OpenBody → incumbent. Target: Strong’s CSV shape (which is also the CSV-import path into Hevy: Hevy accepts Strong-format files). Mapper: mapOpenBodyToStrongmapOpenBodyToStrong(records, opts) → { csv, omissions }, the mirror of mapStrong: the output round-trips through mapStrong back to equivalent records.

What Strong’s CSV can hold (and this mapper writes)

OpenBodyStrong CSV
reps ± an absolute loadReps + Weight (kg; non-kg UCUM mass units converted with exact decimal arithmetic — no float rounding)
bodyweight / reps-only setsReps with Weight 0
duration-scored setsSeconds (UCUM s/min/h/ms converted)
distance-scored setsDistance in metres (km, [mi_i], [yd_i], … converted exactly)
a single-valued RPE effortLoadthe RPE column
per-set notes; workout name / date / durationNotes, Workout Name, Date, Duration
Session.extension["io.strong.export"].workoutNoWorkout No (round-tripped)
Session.workUnits (the collapsed hierarchy telemetry mappers produce)emitted when the unit names its own exerciseRef; reported as an omission otherwise (the CSV keys every row on an Exercise Name)

Exercise names prefer the lossless original source string (opaque — so Hevy → OpenBody → Strong keeps the app’s own names byte-for-byte), then Strong’s own crosswalk alias for a resolved canonical id, then the raw id.

Output (round-tripping the Strong fixture — zero omissions)

Date,Workout Name,Duration,Exercise Name,Set Order,Weight,Reps,Distance,Seconds,Notes,Workout No,RPE
2025-12-20 18:00:00,Push Day,3600,Bench Press (Barbell),1,80,5,0,0,,1,
2025-12-20 18:00:00,Push Day,3600,Bench Press (Barbell),2,80,5,0,0,,1,
2025-12-20 18:00:00,Push Day,3600,Overhead Press (Barbell),1,45,8,0,0,,1,
2025-12-20 18:00:00,Push Day,3600,Plank,1,0,0,0,60,core hold,1,

The degraded case: a %1RM load

Inject load: { value: { relativeToThreshold: { percent: 80, of: "1RM" } } } into the first set and the CSV still comes out — the set survives (reps intact, Weight left at 0) and the loss is reported:

[
{
"recordId": "strong-w-87ab88ae-ex0-set0",
"field": "load",
"reason": "load is 80% of 1RM (this mapper does not resolve ThresholdProfiles to an absolute value) — Strong's Weight column needs an absolute kg value; left at 0"
}
]

What gets reported as an omission

  • Non-Session top-level records (Measurements, Programs, … — no CSV home).
  • WorkUnits with no exerciseRef — nothing to write in the Exercise Name column.
  • Block structure: grouping (supersets), roundScheme, repetitions, block-level scoring schemes (AMRAP/EMOM) — children are flattened to consecutive plain sets.
  • energy/continuous-scored WorkUnits — emitted as plain sets where they carry time/distance/reps; dropped (and reported) when they carry nothing Strong can hold.
  • Metric values Strong’s columns can’t take: range, relativeToThreshold (%1RM), ramp, stopCondition, energy values, loads in units with no exact kg conversion (bands, machine levels).
  • effortLoad entries other than a single-valued RPE.

What is silently dropped

Documented once here, not per row — Strong’s CSV simply has no concept of them, and reporting each would drown the real losses: setRole, rxStatus, prescription (the CSV is a performed log), repDetail, sides, phasePattern, modifiers, intensity, rest, outcome, terminatedBy, per-WorkUnit timestamps, envelope metadata (links / media / provenance), and non-Strong extensions.

Notes

  • Round-trip tested: Strong CSV → OpenBody → Strong CSV → OpenBody is asserted equivalent under the §8.3 rule, including the duration-scored Plank row (Seconds carries the hold).
  • This is the export path behind the browser tool’s “Download as Strong CSV” button at /tools/convert/, omissions summary included.