Skip to content

Commit 45ff795

Browse files
DTTerastarorca-ide
andauthored
feat: routines export with folder sections + fitdown polish (#50)
* feat(routines): add list and show subcommands Adds `liftoff-export routines list` and `routines show <name-or-id>` for exporting saved workout templates. Liftoff calls them "presets" internally under fitnessService.* — the JSON output preserves that naming so jq recipes match the upstream API, while the CLI surface uses "routines" since that's what lifters call them. Read path: fitnessService.fetchUserPresetsWithFolders (single GET, no input). Folder-organized routines emit a stderr warning and are skipped; support is a follow-up once a non-empty folder example is captured. The fitdown set-line renderer is duplicated rather than extracted from workouts.go so that file stays untouched; the WR/BR/AB/WD/DD/ND switch is small enough that one shared helper would not yet pay for itself. Co-Authored-By: Claude Opus 4.7 <[email protected]> Co-authored-by: Orca <[email protected]> * ci: cross-compile review binaries as artifacts (darwin + linux) Adds a small parallel job that cross-compiles darwin-arm64, darwin-amd64, linux-amd64, linux-arm64 and uploads them as a build artifact attached to the workflow run. Reviewers (and the author on a machine without a Go toolchain) can now download the binary right off the PR's checks page rather than rebuilding locally. Goreleaser still owns tagged releases; this is for short-lived review binaries only — retention is 14 days to keep storage tidy. Co-Authored-By: Claude Opus 4.7 <[email protected]> Co-authored-by: Orca <[email protected]> * ci: drop sha from artifact name (already scoped per run) Co-Authored-By: Claude Opus 4.7 <[email protected]> Co-authored-by: Orca <[email protected]> * feat(routines): use markdown H1 + hr between routines for scannability Plain "Routine NAME" headers blended into the exercise list — hard to spot where one routine ended and the next began. Switching to "# Routine: NAME" (markdown H1) plus a "---" horizontal rule between routines gives both terminal readers and markdown renderers a clear break. Co-Authored-By: Claude Opus 4.7 <[email protected]> Co-authored-by: Orca <[email protected]> * feat(routines): render exerciseNotes inline in parens, not as a markdown # "# Left Only" under an exercise name renders as a markdown H1 in any viewer, which is wrong — the note is an annotation, not a heading. Switching to "Kettlebell Swing (Left Only)" keeps the note attached to the exercise without colliding with markdown semantics. (workouts.go has the same issue on SessionNotes — left as a separate follow-up rather than expanding this PR's scope.) Co-Authored-By: Claude Opus 4.7 <[email protected]> Co-authored-by: Orca <[email protected]> * feat(workouts): use parens for SessionNotes + render ExerciseNotes Workouts had "# %s" for SessionNotes — a markdown H1 collision identical to the one just fixed on the routines side. Switching SessionNotes to parens on the "Workout DATE" header, and adding the missing per-exercise note rendering ("Exercise Name (Seat 3)") so workout output matches routine output for the same data shape. Per-exercise notes like "Seat 3" / "Pos 5" / "Left Only" were silently dropped on the workouts side; they now surface in both list and show. Co-Authored-By: Claude Opus 4.7 <[email protected]> Co-authored-by: Orca <[email protected]> * test(cmd): cover renderer + note-in-parens convention Refactors printFitdown / printRoutinesFitdown to take an io.Writer (thin os.Stdout wrappers stay) so the renderers are testable without stdout capture. Adds 12 cases across two new test files: - Routine H1 header + --- separator between routines, no trailing rule. - Single routine omits the separator. - Favorite star renders. - Routine ExerciseNotes render in parens, NOT as a markdown H1. - Consecutive identical sets compress to Nx notation. - pickPreset name/id/case-insensitive/collision/miss behaviors. - Workout SessionNotes render in parens on header, NOT as a markdown H1. - Workout ExerciseNotes render in parens, NOT as a markdown H1. - Fitdown set notation for WR / BR / AB / ND types. The "NOT a markdown H1" guards explicitly catch the old "# %s" pattern so a future regression would surface as a named test failure rather than as a visual quirk in someone's terminal. Co-Authored-By: Claude Opus 4.7 <[email protected]> Co-authored-by: Orca <[email protected]> * feat(routines): render folders as section headings Liftoff lets users group routines into folders ("Valley Creek") and shows the ungrouped section as "My Routines" in-app. The CLI now mirrors that layout: folder names are H1 section headings, routines become H2 under them, with the existing "---" rule separating siblings and section boundaries. - Decode the previously-skipped folders array into typed []Folder, drop the stderr "folders not rendered" warning. - routines list --format json now emits the upstream-faithful nested shape ({folders, presetsWithoutFolder}) so foldered presets are surfaced instead of silently dropped. - pickPreset searches across both unfiled and foldered presets so `routines show "Valley Creek 1"` works regardless of where the routine lives. - Empty unfiled section is suppressed so an account with only foldered routines doesn't emit a stray "# My Routines" heading. - Tests cover folder rendering, H2-inside-folder, cross-folder pickPreset, and the empty-section guard. Co-Authored-By: Claude Opus 4.7 <[email protected]> Co-authored-by: Orca <[email protected]> --------- Co-authored-by: Orca <[email protected]>
1 parent e575e48 commit 45ff795

8 files changed

Lines changed: 686 additions & 9 deletions

File tree

.github/workflows/ci.yml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,32 @@ jobs:
4545
# Path the compat-tagged test reads from os.Getenv.
4646
LIFTOFF_EXPORT_BIN: /tmp/liftoff-export
4747
run: go test -tags=compat -run TestContractFormats ./...
48+
49+
artifacts:
50+
# Cross-compile review binaries for the platforms reviewers actually use.
51+
# Goreleaser owns tagged releases; this job exists so a PR's diff can be
52+
# tried end-to-end without a local Go toolchain — download from the run
53+
# page, chmod +x, run. Retention is short (14d) to keep storage tidy.
54+
name: review binaries
55+
runs-on: ubuntu-latest
56+
steps:
57+
- uses: actions/checkout@v4
58+
- uses: actions/setup-go@v5
59+
with:
60+
go-version-file: go.mod
61+
cache: true
62+
- name: cross-compile
63+
run: |
64+
mkdir -p dist
65+
for target in darwin/arm64 darwin/amd64 linux/amd64 linux/arm64; do
66+
os="${target%/*}"; arch="${target#*/}"
67+
GOOS=$os GOARCH=$arch go build -trimpath -ldflags="-s -w" \
68+
-o "dist/liftoff-export-${os}-${arch}" .
69+
done
70+
ls -lh dist/
71+
- uses: actions/upload-artifact@v4
72+
with:
73+
name: liftoff-export
74+
path: dist/
75+
retention-days: 14
76+
if-no-files-found: error

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,19 @@ liftoff-export bodyweights stats # Stats with monthly graph an
111111
liftoff-export bodyweights stats --since 2025-01-01
112112
```
113113

114+
### Routines
115+
116+
Routines are reusable workout templates saved in the Liftoff app (the upstream API calls them "presets"; the JSON output preserves that naming):
117+
118+
```sh
119+
liftoff-export routines list # List all your saved routines (fitdown)
120+
liftoff-export routines list --format json # Full JSON for jq / agents
121+
liftoff-export routines show Push # One routine by name (case-insensitive)
122+
liftoff-export routines show cmkbk9ugu0eej3pv0oyd41x8c # …or by id
123+
```
124+
125+
Folder-organized routines are not rendered yet — file an issue if you need folder support.
126+
114127
## Output Format
115128

116129
Workouts are printed in [fitdown](https://github.com/datavis-tech/fitdown) format by default:

cmd/prime.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ SUBCOMMANDS
2929
Filters: --exercise NAME, --detail
3030
bodyweights list Recorded bodyweights, one per line
3131
bodyweights stats Current/high/low + monthly trend + plateau
32+
routines list Saved workout routines (fitdown notation)
33+
routines show NAME-OR-ID One routine by name (case-insensitive) or id
3234
3335
Inspect any subcommand's row schema with: <subcommand> --since 1d --format json
3436
@@ -38,6 +40,8 @@ EXAMPLES
3840
jq '.[] | select(.type == "WR") | {name, vol: ([.sessions[].volume] | add)}'
3941
liftoff-export bodyweights list --since 90d --format json |
4042
jq '[.[]] | (.[-1].weight - .[0].weight)'
43+
liftoff-export routines list --format json |
44+
jq '[.[] | {name, exCount: (.exerciseData|length)}]'
4145
4246
GOTCHAS
4347
- Workout dates are LOCAL — 11pm workouts bucket on the day you logged them.
@@ -47,6 +51,10 @@ GOTCHAS
4751
workout). No workout that day means no bodyweight that day.
4852
- 'workouts stats' bins exercises by name. Renaming an exercise in
4953
Liftoff splits it into two summaries.
54+
- 'routines' are what Liftoff calls "presets" internally; the JSON
55+
preserves that naming (id, name, exerciseData, isFavorite, etc.).
56+
Folder-organized routines aren't rendered yet — file an issue if you
57+
organize routines into folders.
5058
`
5159

5260
var primeCmd = &cobra.Command{

cmd/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,5 +26,6 @@ func Execute() {
2626
func init() {
2727
rootCmd.AddCommand(authCmd)
2828
rootCmd.AddCommand(bodyweightsCmd)
29+
rootCmd.AddCommand(routinesCmd)
2930
rootCmd.AddCommand(workoutsCmd)
3031
}

cmd/routines.go

Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
package cmd
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"io"
7+
"os"
8+
"strings"
9+
10+
"github.com/quantcli/liftoff-export-cli/internal/client"
11+
"github.com/spf13/cobra"
12+
)
13+
14+
// Preset mirrors a Liftoff fitnessService preset (what the app calls a saved
15+
// workout template; users call them "routines"). The field names match the
16+
// upstream JSON so `--format json | jq` reads naturally against API docs.
17+
type Preset struct {
18+
ID string `json:"id"`
19+
CreatedAt string `json:"createdAt"`
20+
UserID string `json:"userId"`
21+
Name string `json:"name"`
22+
Image *string `json:"image"`
23+
MarketPresetID *string `json:"marketPresetId"`
24+
BookmarkID string `json:"bookmarkId"`
25+
Completed int `json:"completed"`
26+
AvgDuration int `json:"avgDuration"`
27+
IsFavorite bool `json:"isFavorite"`
28+
FolderID *string `json:"folderId"`
29+
ExerciseData []PresetExerciseData `json:"exerciseData"`
30+
}
31+
32+
// PresetExerciseData mirrors an entry in a preset's exerciseData array.
33+
// Shape parallels workouts.ExerciseData, with the additional preset linkage
34+
// fields (presetCuid, exerciseDataId on sets).
35+
type PresetExerciseData struct {
36+
ID string `json:"id"`
37+
PresetCUID string `json:"presetCuid"`
38+
ExerciseIndex int `json:"exerciseIndex"`
39+
ExerciseName string `json:"exerciseName"`
40+
ExerciseID string `json:"exerciseId"`
41+
ExerciseTypes string `json:"exerciseTypes"`
42+
Superset *string `json:"superset"`
43+
OverrideWeightUnit *string `json:"overrideWeightUnit"`
44+
ExerciseCUID *string `json:"exerciseCuid"`
45+
MarketExerciseCUID *string `json:"marketExerciseCuid"`
46+
ExerciseNotes *string `json:"exerciseNotes"`
47+
SetsData []PresetSetData `json:"setsData"`
48+
}
49+
50+
type PresetSetData struct {
51+
ID string `json:"id"`
52+
ExerciseDataID string `json:"exerciseDataId"`
53+
SetIndex int `json:"setIndex"`
54+
SetType string `json:"setType"`
55+
InputOne json.Number `json:"inputOne"`
56+
InputTwo json.Number `json:"inputTwo"`
57+
}
58+
59+
// Folder is a Liftoff routine folder — a labeled group of presets in the
60+
// app's Routines tab. The nested Presets array contains the full Preset
61+
// objects (each with folderId pointing back to this folder).
62+
type Folder struct {
63+
ID string `json:"id"`
64+
CreatedAt string `json:"createdAt"`
65+
UserID string `json:"userId"`
66+
Name string `json:"name"`
67+
PresetsOrder []string `json:"presetsOrder"`
68+
Presets []Preset `json:"presets"`
69+
}
70+
71+
// presetsResponse is the top-level shape of fitnessService.fetchUserPresetsWithFolders.
72+
// PresetsWithoutFolder are what the Liftoff app surfaces under "My Routines"
73+
// (the implicit ungrouped section); folder Presets are surfaced under their
74+
// folder name. Both rendering paths share the same Preset shape.
75+
type presetsResponse struct {
76+
Folders []Folder `json:"folders"`
77+
PresetsWithoutFolder []Preset `json:"presetsWithoutFolder"`
78+
}
79+
80+
// unfiledLabel is the section title we use for PresetsWithoutFolder in
81+
// markdown output. Matches the Liftoff app's UI label so a user comparing
82+
// terminal output to the app sees the same heading.
83+
const unfiledLabel = "My Routines"
84+
85+
var routinesCmd = &cobra.Command{
86+
Use: "routines",
87+
Short: "Saved workout routine (preset) commands",
88+
Long: `Routines are reusable workout templates saved in the Liftoff app.
89+
The upstream API calls them "presets"; the JSON output preserves that
90+
naming. The fitdown markdown renderer treats a routine the same way it
91+
treats a logged workout.`,
92+
}
93+
94+
var routinesListFormatFlag string
95+
96+
var routinesListCmd = &cobra.Command{
97+
Use: "list",
98+
Short: "List all your saved routines",
99+
RunE: func(cmd *cobra.Command, args []string) error {
100+
format, err := validateFormat(routinesListFormatFlag)
101+
if err != nil {
102+
return err
103+
}
104+
resp, err := fetchPresets()
105+
if err != nil {
106+
return err
107+
}
108+
if format == "json" {
109+
return printJSON(resp)
110+
}
111+
return renderRoutinesFitdown(os.Stdout, resp)
112+
},
113+
}
114+
115+
var routinesShowFormatFlag string
116+
117+
var routinesShowCmd = &cobra.Command{
118+
Use: "show <name-or-id>",
119+
Short: "Show one routine by name (case-insensitive exact match) or by id",
120+
Args: cobra.ExactArgs(1),
121+
RunE: func(cmd *cobra.Command, args []string) error {
122+
format, err := validateFormat(routinesShowFormatFlag)
123+
if err != nil {
124+
return err
125+
}
126+
resp, err := fetchPresets()
127+
if err != nil {
128+
return err
129+
}
130+
match, err := pickPreset(resp, args[0])
131+
if err != nil {
132+
return err
133+
}
134+
if format == "json" {
135+
return printJSON([]Preset{*match})
136+
}
137+
// `show` emits a single routine without any section heading —
138+
// the routine name is enough context for a one-off lookup.
139+
return renderOnePreset(os.Stdout, *match, "#")
140+
},
141+
}
142+
143+
func fetchPresets() (*presetsResponse, error) {
144+
c := client.New()
145+
var resp presetsResponse
146+
if err := c.Query("fitnessService.fetchUserPresetsWithFolders", nil, &resp); err != nil {
147+
return nil, err
148+
}
149+
// Normalize nil → empty slice so --format json emits `[]` not `null`,
150+
// matching what the upstream API does on an empty account.
151+
if resp.Folders == nil {
152+
resp.Folders = []Folder{}
153+
}
154+
if resp.PresetsWithoutFolder == nil {
155+
resp.PresetsWithoutFolder = []Preset{}
156+
}
157+
return &resp, nil
158+
}
159+
160+
// allPresets flattens both unfiled and foldered presets into one slice for
161+
// lookup. Used by pickPreset so `routines show "Valley Creek 1"` finds a
162+
// routine regardless of whether it lives in a folder.
163+
func allPresets(resp *presetsResponse) []Preset {
164+
out := make([]Preset, 0, len(resp.PresetsWithoutFolder))
165+
out = append(out, resp.PresetsWithoutFolder...)
166+
for _, f := range resp.Folders {
167+
out = append(out, f.Presets...)
168+
}
169+
return out
170+
}
171+
172+
// pickPreset resolves a `show` argument to exactly one preset across the
173+
// full account (unfiled + foldered). Match order: (1) case-insensitive
174+
// exact name match, (2) exact id match. Multiple name-matches return an
175+
// error so the caller can disambiguate by id.
176+
func pickPreset(resp *presetsResponse, arg string) (*Preset, error) {
177+
presets := allPresets(resp)
178+
target := strings.ToLower(strings.TrimSpace(arg))
179+
var nameHits []Preset
180+
for _, p := range presets {
181+
if strings.ToLower(p.Name) == target {
182+
nameHits = append(nameHits, p)
183+
}
184+
}
185+
if len(nameHits) == 1 {
186+
return &nameHits[0], nil
187+
}
188+
if len(nameHits) > 1 {
189+
ids := make([]string, 0, len(nameHits))
190+
for _, p := range nameHits {
191+
ids = append(ids, p.ID)
192+
}
193+
return nil, fmt.Errorf("multiple routines named %q — disambiguate by id: %s", arg, strings.Join(ids, ", "))
194+
}
195+
for i := range presets {
196+
if presets[i].ID == arg {
197+
return &presets[i], nil
198+
}
199+
}
200+
return nil, fmt.Errorf("no routine matches %q", arg)
201+
}
202+
203+
// renderRoutinesFitdown renders the full account: a "# My Routines" H1 with
204+
// each unfiled preset as an H2 underneath, then "# <FolderName>" H1 per
205+
// folder with its foldered presets as H2. "---" rules separate sibling
206+
// routines and section boundaries. Favorite routines are marked with a
207+
// star; per-exercise notes are appended in parens so cues like "Left Only"
208+
// aren't rendered as a markdown heading by mistake.
209+
func renderRoutinesFitdown(w io.Writer, resp *presetsResponse) error {
210+
first := true
211+
emitSection := func(heading string, presets []Preset) {
212+
if len(presets) == 0 {
213+
return
214+
}
215+
if !first {
216+
fmt.Fprintln(w)
217+
fmt.Fprintln(w, "---")
218+
fmt.Fprintln(w)
219+
}
220+
first = false
221+
fmt.Fprintf(w, "# %s\n", heading)
222+
for i, p := range presets {
223+
if i > 0 {
224+
fmt.Fprintln(w)
225+
fmt.Fprintln(w, "---")
226+
}
227+
fmt.Fprintln(w)
228+
renderOnePreset(w, p, "##")
229+
}
230+
}
231+
emitSection(unfiledLabel, resp.PresetsWithoutFolder)
232+
for _, f := range resp.Folders {
233+
emitSection(f.Name, f.Presets)
234+
}
235+
return nil
236+
}
237+
238+
// renderOnePreset prints a single preset under the given heading marker
239+
// (e.g. "##" inside a folder section, "#" for `routines show` where the
240+
// routine has no enclosing section). Body format (exercises, set lines,
241+
// note parens) is identical regardless of caller.
242+
func renderOnePreset(w io.Writer, p Preset, headingMarker string) error {
243+
star := ""
244+
if p.IsFavorite {
245+
star = " ★"
246+
}
247+
fmt.Fprintf(w, "%s Routine: %s%s\n", headingMarker, p.Name, star)
248+
for _, ex := range p.ExerciseData {
249+
fmt.Fprintln(w)
250+
name := ex.ExerciseName
251+
if ex.ExerciseNotes != nil && *ex.ExerciseNotes != "" {
252+
name = fmt.Sprintf("%s (%s)", name, *ex.ExerciseNotes)
253+
}
254+
fmt.Fprintln(w, name)
255+
var lines []string
256+
for _, s := range ex.SetsData {
257+
lines = append(lines, fitdownSetLine(ex.ExerciseTypes, s))
258+
}
259+
// Compress consecutive identical lines into Nx... notation.
260+
for i := 0; i < len(lines); {
261+
j := i + 1
262+
for j < len(lines) && lines[j] == lines[i] {
263+
j++
264+
}
265+
if n := j - i; n > 1 {
266+
fmt.Fprintf(w, "%dx%s\n", n, lines[i])
267+
} else {
268+
fmt.Fprintln(w, lines[i])
269+
}
270+
i = j
271+
}
272+
}
273+
return nil
274+
}
275+
276+
// fitdownSetLine is split out of workouts.printFitdown so routines can render
277+
// the same notation without duplicating the type switch. Keeping it here
278+
// rather than in workouts.go keeps the workouts file untouched, at the cost
279+
// of a tiny bit of duplication of the WR/BR/AB/WD/DD/ND mapping.
280+
func fitdownSetLine(exTypes string, s PresetSetData) string {
281+
switch exTypes {
282+
case "WR":
283+
return fmt.Sprintf("%s@%s", s.InputTwo, s.InputOne)
284+
case "AB":
285+
return fmt.Sprintf("%s@-%s", s.InputTwo, s.InputOne)
286+
case "BR":
287+
return fmt.Sprintf("%s@+%s", s.InputTwo, s.InputOne)
288+
case "WD":
289+
km, _ := s.InputTwo.Float64()
290+
return fmt.Sprintf("%slb %.3fmi", s.InputOne, km/1.60934)
291+
case "DD":
292+
secs, _ := s.InputTwo.Int64()
293+
km, _ := s.InputOne.Float64()
294+
return fmt.Sprintf("%.2fmi %d:%02d", km/1.60934, secs/60, secs%60)
295+
case "ND":
296+
secs, _ := s.InputTwo.Int64()
297+
return fmt.Sprintf("%d:%02d", secs/60, secs%60)
298+
default:
299+
return fmt.Sprintf("[%s] %s %s", exTypes, s.InputOne, s.InputTwo)
300+
}
301+
}
302+
303+
func init() {
304+
routinesCmd.AddCommand(routinesListCmd)
305+
routinesCmd.AddCommand(routinesShowCmd)
306+
routinesListCmd.Flags().StringVar(&routinesListFormatFlag, "format", "markdown",
307+
"Output format: markdown (default, fitdown-style) or json")
308+
routinesShowCmd.Flags().StringVar(&routinesShowFormatFlag, "format", "markdown",
309+
"Output format: markdown (default, fitdown-style) or json")
310+
}

0 commit comments

Comments
 (0)