Faithful to the whiteboard
Faithful
Represent real coach notation — per-lane intervals @1:30/1:40/1:50, brace repeats, footnotes — not an idealized model.
Layered semantics
Structure everything that has meaning, and keep verbatim free text (note, sourceText) at every level — nothing is ever lost to parsing.
Computable
Per-group distances and duration estimates derive mechanically; stated totals act as checksums — useful for validating OCR imports.
Open
Versioned spec, published JSON Schema, MIT-licensed reference implementation (SwimWorkoutKit, Swift).
SwimText ⇄ JSON
A real example from the spec repository: friday-sprint, a 3,200-yard short-course sprint workout for three speed groups. The JSON below is abridged to the interesting parts — the full file is downloadable.
# Friday — Sprint
course: scy
categories: sprint
total: 3200
== Warmup: 300
3x100 as swim/kick/pull
== Main Set: 2800
3x300 free descend to 80% @4:40/4:50/5:00 (ideally :40 rest)
100 kick easy r1:00
4x200 free descend to 90% @3:10/3:20/3:30 (ideally :30 rest)
100 kick easy r1:00
5x100 choice descend to 95% @1:30/1:40/1:50 (ideally :20 rest)
100 kick easy r1:00
6x50 choice descend to 100% @1:00/1:10 (ideally :15 rest)
== Cool Down: 100
100 choice easy
{
"format": "open-swim-workout",
"version": "0.1",
"workout": {
"title": "Friday — Sprint",
"course": { "length": 25, "unit": "yd" },
"categories": ["sprint"],
"groups": [ { "id": "A" }, { "id": "B" }, { "id": "C" } ],
"sections": [
{ "name": "Warmup", "statedDistance": 300, "items": [ … ] },
{ "name": "Main Set", "statedDistance": 2800, "items": [
… the 3×300 and 4×200 sets, easy kick 100s …
{
"type": "set",
"reps": 5,
"distance": 100,
"stroke": "choice",
"effort": { "shape": "descend", "percent": 95 },
"interval": {
"mode": "sendoff",
"sendoffs": { "A": "1:30", "B": "1:40", "C": "1:50" },
"targetRest": ":20"
},
"sourceText": "5x100 choice descend to 95% @1:30/1:40/1:50 (ideally :20 rest)"
},
… the 6×50 set …
] },
{ "name": "Cool Down", "statedDistance": 100, "items": [ … ] }
],
"statedTotal": 3200
}
}
Repeat blocks
Brace repeats nest, carry per-round variations, and can even run a different number of rounds per group. From the wednesday-im-nested example:
== Main Set: 2000
2x {
4x75 imo as kick/drill/swim r:20
100 free sprint @1:30/1:45/2:00 (~:15 rest)
100 im sprint @1:30/1:45/2:00/2:20 (~:10 rest)
100 choice easy r1:00
75 (25 fly/25 back/25 breast) sprint @1:10/1:20/1:30 (~:10 rest)
…
}
{
"type": "repeat",
"rounds": 2,
"items": [
{
"type": "set",
"reps": 4, "distance": 75, "stroke": "imo",
"segments": [
{ "distance": 25, "activity": "kick" },
{ "distance": 25, "activity": "drill" },
{ "distance": 25, "activity": "swim" }
],
"interval": { "mode": "rest", "rest": ":20" }
},
{
"type": "set",
"reps": 1, "distance": 100, "stroke": "free",
"effort": { "level": "sprint" },
"interval": {
"mode": "sendoff",
"sendoffs": { "A": "1:30", "B": "1:45", "C": "2:00" },
"targetRest": ":15"
}
},
…
]
}
Speed groups: one workout, many speeds
A speed group is a lane or pace cohort, declared once per workout and ordered fastest → slowest. Sets carry a send-off map from group id to time — exactly what coaches write as @1:30/1:45/2:00. Groups can also override reps or distance per set, and items can be filtered to specific groups.
Sparse maps are legal — and resolved deterministically. A group with no entry resolves to the nearest faster (earlier-listed) specified group, then to the nearest slower one. That encodes the everyday reality of a coach listing three times in a four-lane workout. In wednesday-im-nested, the line 100 free sprint @1:30/1:45/2:00 appears in a workout with four groups:
roundsPerGroup sparsely the same way.
Strokes, activities, effort
Stroke and activity are orthogonal axes: “fly drill” is simply stroke: fly + activity: drill — a combination most formats cannot express.
stroke
freefreestylebackbackstrokebreastbreaststrokeflybutterflyimindividual medleyimoIM order, rotating across reps/roundsrimoreverse IM orderstrokethe swimmer’s specialty strokechoiceswimmer’s choicemixedvaries — seesegments/perRep
activity
swimfull-stroke swimming (default)kickkicking, with or without a boardpullpulling — pairs with buoy/paddlesdrilltechnique drill; name indrillNamescullscullingmixedvaries within the set
Equipment rides alongside: fins, paddles, buoy, snorkel, board.
effort
leveleasy · smooth · moderate · strong · fast · sprint · max · raceshapesteady · build · descend · ascend · negative-split · variable-sprintpercent“% effort,” optionally a range viapercentMax; withdescend, the target — “descend to 90%”detailfree-form shape detail:"1-4","by 25","by round"
| Interval mode | Meaning | Extras |
|---|---|---|
sendoff |
Leave on a clock time; sendoffs maps group id → time |
targetRest (“ideally :15 rest”), maxRest, openEnded (“@1:25+” — slowest listed send-off is a floor) |
rest |
Fixed rest between reps, in rest |
SwimText: r:20, r1:00, r20s, r1m |
open |
No clock |
All times are strings — "1:30", ":35", or bare seconds "35" — emitted canonically as m:ss, with :ss for sub-minute values. Validators flag non-positive reps, segment sums that don’t match the rep distance, undefined group ids, totals matching no group, distances not divisible by the course length, and send-offs implausibly shorter than the estimated swim time.
Schema & examples
Everything is versioned with the spec (semantic versioning: readers MUST reject unknown major versions, SHOULD accept unknown minors, and ignore unknown fields — ideally preserving them on rewrite).
SwimWorkoutKit
A UI-free Swift package: the OSW model with Codable, the SwimText parser, printer, and lexer, per-group totals and duration calculators, and the validator — unit-tested against a corpus of representative swim workouts covering the format's features. An optional SwimWorkoutKitUI module adds SwimText syntax highlighting (see below).
- swimtext to-json — parse SwimText into a .swimworkout document.
- swimtext to-text — print a document back as SwimText.
- swimtext check — validate and report per-group distance and estimated duration.
Parsing never rejects input: unrecognized fragments land in note, whole unparsed lines become note items — and the caller is told.
$ swimtext check friday-sprint.swimtext
group A: 3200 yd, ~40:10 (partial)
group B: 3200 yd, ~43:10 (partial)
group C: 3200 yd, ~45:10 (partial)
repetition_time. Apple WorkoutKit (watchOS 11+): one group, send-offs → poolSwimDistanceWithTime goals, stroke/activity → step display names. SwimText round-trips losslessly for the canonical subset.
SwimText syntax highlighting, built in
The optional SwimWorkoutKitUI module colors SwimText with the same visual vocabulary as the app and its deck sheets — strokes, activities, effort, send-offs, sections, and notes — so a typed or pasted workout reads at a glance. It's color-blind-safe and pairs color with weight, staying legible in black & white.
- SwimTextView — a UITextView / NSTextView subclass that highlights live as the text changes. Set its text and it colors itself; type into it and it re-highlights on every keystroke.
- SwimTextEditor — a SwiftUI wrapper around SwimTextView: a drop-in, highlighting replacement for TextEditor.
- SwimTextLabel — a read-only SwiftUI view (renders into Text), so it works everywhere, including watchOS.
- SwimTextHighlighter — the engine: SwimText → AttributedString or NSAttributedString. Built on the UI-free SwimTextLexer in the core library.
- SwimTextTheme — the palette. .standard matches the app; pass your own to any view to retheme.
import SwiftUI
import SwimWorkoutKitUI
struct WorkoutEntry: View {
@State private var text = "8x50 free fast @:45 r:10"
var body: some View {
SwimTextEditor(text: $text) // live highlighting
}
}
# Friday — Sprint
course: scy
== Warmup: 300
3x100 as swim/kick/pull
== Main Set: 2800
3x300 free descend to 80% @4:40/4:50/5:00
2x {
6x25 fly sprint @:35
100 back smooth r:20 | long axis
}
> remember your fins