From b62f28a38bd3d7d7ef77836d7169e4e844b300b1 Mon Sep 17 00:00:00 2001
From: DTTerastar
Date: Mon, 8 Jun 2026 18:51:05 -0400
Subject: [PATCH 1/8] feat(routines): add list and show subcommands
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds `liftoff-export routines list` and `routines show ` 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
Co-authored-by: Orca
---
README.md | 13 +++
cmd/prime.go | 8 ++
cmd/root.go | 1 +
cmd/routines.go | 240 ++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 262 insertions(+)
create mode 100644 cmd/routines.go
diff --git a/README.md b/README.md
index 50b9754..c60f8f4 100644
--- a/README.md
+++ b/README.md
@@ -111,6 +111,19 @@ liftoff-export bodyweights stats # Stats with monthly graph an
liftoff-export bodyweights stats --since 2025-01-01
```
+### Routines
+
+Routines are reusable workout templates saved in the Liftoff app (the upstream API calls them "presets"; the JSON output preserves that naming):
+
+```sh
+liftoff-export routines list # List all your saved routines (fitdown)
+liftoff-export routines list --format json # Full JSON for jq / agents
+liftoff-export routines show Push # One routine by name (case-insensitive)
+liftoff-export routines show cmkbk9ugu0eej3pv0oyd41x8c # …or by id
+```
+
+Folder-organized routines are not rendered yet — file an issue if you need folder support.
+
## Output Format
Workouts are printed in [fitdown](https://github.com/datavis-tech/fitdown) format by default:
diff --git a/cmd/prime.go b/cmd/prime.go
index bcbe04d..8bb1abb 100644
--- a/cmd/prime.go
+++ b/cmd/prime.go
@@ -29,6 +29,8 @@ SUBCOMMANDS
Filters: --exercise NAME, --detail
bodyweights list Recorded bodyweights, one per line
bodyweights stats Current/high/low + monthly trend + plateau
+ routines list Saved workout routines (fitdown notation)
+ routines show NAME-OR-ID One routine by name (case-insensitive) or id
Inspect any subcommand's row schema with: --since 1d --format json
@@ -38,6 +40,8 @@ EXAMPLES
jq '.[] | select(.type == "WR") | {name, vol: ([.sessions[].volume] | add)}'
liftoff-export bodyweights list --since 90d --format json |
jq '[.[]] | (.[-1].weight - .[0].weight)'
+ liftoff-export routines list --format json |
+ jq '[.[] | {name, exCount: (.exerciseData|length)}]'
GOTCHAS
- Workout dates are LOCAL — 11pm workouts bucket on the day you logged them.
@@ -47,6 +51,10 @@ GOTCHAS
workout). No workout that day means no bodyweight that day.
- 'workouts stats' bins exercises by name. Renaming an exercise in
Liftoff splits it into two summaries.
+ - 'routines' are what Liftoff calls "presets" internally; the JSON
+ preserves that naming (id, name, exerciseData, isFavorite, etc.).
+ Folder-organized routines aren't rendered yet — file an issue if you
+ organize routines into folders.
`
var primeCmd = &cobra.Command{
diff --git a/cmd/root.go b/cmd/root.go
index a1cdfcb..090ce9e 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -26,5 +26,6 @@ func Execute() {
func init() {
rootCmd.AddCommand(authCmd)
rootCmd.AddCommand(bodyweightsCmd)
+ rootCmd.AddCommand(routinesCmd)
rootCmd.AddCommand(workoutsCmd)
}
diff --git a/cmd/routines.go b/cmd/routines.go
new file mode 100644
index 0000000..cd04837
--- /dev/null
+++ b/cmd/routines.go
@@ -0,0 +1,240 @@
+package cmd
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "strings"
+
+ "github.com/quantcli/liftoff-export-cli/internal/client"
+ "github.com/spf13/cobra"
+)
+
+// Preset mirrors a Liftoff fitnessService preset (what the app calls a saved
+// workout template; users call them "routines"). The field names match the
+// upstream JSON so `--format json | jq` reads naturally against API docs.
+type Preset struct {
+ ID string `json:"id"`
+ CreatedAt string `json:"createdAt"`
+ UserID string `json:"userId"`
+ Name string `json:"name"`
+ Image *string `json:"image"`
+ MarketPresetID *string `json:"marketPresetId"`
+ BookmarkID string `json:"bookmarkId"`
+ Completed int `json:"completed"`
+ AvgDuration int `json:"avgDuration"`
+ IsFavorite bool `json:"isFavorite"`
+ FolderID *string `json:"folderId"`
+ ExerciseData []PresetExerciseData `json:"exerciseData"`
+}
+
+// PresetExerciseData mirrors an entry in a preset's exerciseData array.
+// Shape parallels workouts.ExerciseData, with the additional preset linkage
+// fields (presetCuid, exerciseDataId on sets).
+type PresetExerciseData struct {
+ ID string `json:"id"`
+ PresetCUID string `json:"presetCuid"`
+ ExerciseIndex int `json:"exerciseIndex"`
+ ExerciseName string `json:"exerciseName"`
+ ExerciseID string `json:"exerciseId"`
+ ExerciseTypes string `json:"exerciseTypes"`
+ Superset *string `json:"superset"`
+ OverrideWeightUnit *string `json:"overrideWeightUnit"`
+ ExerciseCUID *string `json:"exerciseCuid"`
+ MarketExerciseCUID *string `json:"marketExerciseCuid"`
+ ExerciseNotes *string `json:"exerciseNotes"`
+ SetsData []PresetSetData `json:"setsData"`
+}
+
+type PresetSetData struct {
+ ID string `json:"id"`
+ ExerciseDataID string `json:"exerciseDataId"`
+ SetIndex int `json:"setIndex"`
+ SetType string `json:"setType"`
+ InputOne json.Number `json:"inputOne"`
+ InputTwo json.Number `json:"inputTwo"`
+}
+
+// presetsResponse is the top-level shape of fitnessService.fetchUserPresetsWithFolders.
+// We currently surface only presetsWithoutFolder; folders trigger a stderr
+// warning so a non-empty value isn't silently dropped. Folder support is a
+// follow-up once we have a non-empty example to model.
+type presetsResponse struct {
+ Folders json.RawMessage `json:"folders"`
+ PresetsWithoutFolder []Preset `json:"presetsWithoutFolder"`
+}
+
+var routinesCmd = &cobra.Command{
+ Use: "routines",
+ Short: "Saved workout routine (preset) commands",
+ Long: `Routines are reusable workout templates saved in the Liftoff app.
+The upstream API calls them "presets"; the JSON output preserves that
+naming. The fitdown markdown renderer treats a routine the same way it
+treats a logged workout.`,
+}
+
+var routinesListFormatFlag string
+
+var routinesListCmd = &cobra.Command{
+ Use: "list",
+ Short: "List all your saved routines",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ format, err := validateFormat(routinesListFormatFlag)
+ if err != nil {
+ return err
+ }
+ presets, err := fetchPresets()
+ if err != nil {
+ return err
+ }
+ if format == "json" {
+ return printJSON(presets)
+ }
+ return printRoutinesFitdown(presets)
+ },
+}
+
+var routinesShowFormatFlag string
+
+var routinesShowCmd = &cobra.Command{
+ Use: "show ",
+ Short: "Show one routine by name (case-insensitive exact match) or by id",
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ format, err := validateFormat(routinesShowFormatFlag)
+ if err != nil {
+ return err
+ }
+ presets, err := fetchPresets()
+ if err != nil {
+ return err
+ }
+ match, err := pickPreset(presets, args[0])
+ if err != nil {
+ return err
+ }
+ if format == "json" {
+ return printJSON([]Preset{*match})
+ }
+ return printRoutinesFitdown([]Preset{*match})
+ },
+}
+
+func fetchPresets() ([]Preset, error) {
+ c := client.New()
+ var resp presetsResponse
+ if err := c.Query("fitnessService.fetchUserPresetsWithFolders", nil, &resp); err != nil {
+ return nil, err
+ }
+ if len(resp.Folders) > 0 && string(resp.Folders) != "[]" && string(resp.Folders) != "null" {
+ fmt.Fprintln(os.Stderr, "liftoff-export: warning — routine folders detected but not yet rendered; presets outside folders will be shown. File a follow-up if you need folder support.")
+ }
+ return resp.PresetsWithoutFolder, nil
+}
+
+// pickPreset resolves a `show` argument to exactly one preset. Match order:
+// (1) case-insensitive exact name match, (2) exact id match. Multiple
+// name-matches return an error so the caller can disambiguate by id.
+func pickPreset(presets []Preset, arg string) (*Preset, error) {
+ target := strings.ToLower(strings.TrimSpace(arg))
+ var nameHits []Preset
+ for _, p := range presets {
+ if strings.ToLower(p.Name) == target {
+ nameHits = append(nameHits, p)
+ }
+ }
+ if len(nameHits) == 1 {
+ return &nameHits[0], nil
+ }
+ if len(nameHits) > 1 {
+ ids := make([]string, 0, len(nameHits))
+ for _, p := range nameHits {
+ ids = append(ids, p.ID)
+ }
+ return nil, fmt.Errorf("multiple routines named %q — disambiguate by id: %s", arg, strings.Join(ids, ", "))
+ }
+ for i := range presets {
+ if presets[i].ID == arg {
+ return &presets[i], nil
+ }
+ }
+ return nil, fmt.Errorf("no routine matches %q", arg)
+}
+
+// printRoutinesFitdown renders presets in the same fitdown notation
+// `workouts list` uses. Each routine gets a header; favorite routines are
+// marked with a star; exerciseNotes per-exercise are surfaced below the
+// exercise name so cues like "Left Only" aren't lost.
+func printRoutinesFitdown(presets []Preset) error {
+ for i, p := range presets {
+ star := ""
+ if p.IsFavorite {
+ star = " ★"
+ }
+ fmt.Printf("Routine %s%s\n", p.Name, star)
+ for _, ex := range p.ExerciseData {
+ fmt.Println()
+ fmt.Println(ex.ExerciseName)
+ if ex.ExerciseNotes != nil && *ex.ExerciseNotes != "" {
+ fmt.Printf("# %s\n", *ex.ExerciseNotes)
+ }
+ var lines []string
+ for _, s := range ex.SetsData {
+ lines = append(lines, fitdownSetLine(ex.ExerciseTypes, s))
+ }
+ // Compress consecutive identical lines into Nx... notation
+ for i := 0; i < len(lines); {
+ j := i + 1
+ for j < len(lines) && lines[j] == lines[i] {
+ j++
+ }
+ if n := j - i; n > 1 {
+ fmt.Printf("%dx%s\n", n, lines[i])
+ } else {
+ fmt.Println(lines[i])
+ }
+ i = j
+ }
+ }
+ if i < len(presets)-1 {
+ fmt.Println()
+ }
+ }
+ return nil
+}
+
+// fitdownSetLine is split out of workouts.printFitdown so routines can render
+// the same notation without duplicating the type switch. Keeping it here
+// rather than in workouts.go keeps the workouts file untouched, at the cost
+// of a tiny bit of duplication of the WR/BR/AB/WD/DD/ND mapping.
+func fitdownSetLine(exTypes string, s PresetSetData) string {
+ switch exTypes {
+ case "WR":
+ return fmt.Sprintf("%s@%s", s.InputTwo, s.InputOne)
+ case "AB":
+ return fmt.Sprintf("%s@-%s", s.InputTwo, s.InputOne)
+ case "BR":
+ return fmt.Sprintf("%s@+%s", s.InputTwo, s.InputOne)
+ case "WD":
+ km, _ := s.InputTwo.Float64()
+ return fmt.Sprintf("%slb %.3fmi", s.InputOne, km/1.60934)
+ case "DD":
+ secs, _ := s.InputTwo.Int64()
+ km, _ := s.InputOne.Float64()
+ return fmt.Sprintf("%.2fmi %d:%02d", km/1.60934, secs/60, secs%60)
+ case "ND":
+ secs, _ := s.InputTwo.Int64()
+ return fmt.Sprintf("%d:%02d", secs/60, secs%60)
+ default:
+ return fmt.Sprintf("[%s] %s %s", exTypes, s.InputOne, s.InputTwo)
+ }
+}
+
+func init() {
+ routinesCmd.AddCommand(routinesListCmd)
+ routinesCmd.AddCommand(routinesShowCmd)
+ routinesListCmd.Flags().StringVar(&routinesListFormatFlag, "format", "markdown",
+ "Output format: markdown (default, fitdown-style) or json")
+ routinesShowCmd.Flags().StringVar(&routinesShowFormatFlag, "format", "markdown",
+ "Output format: markdown (default, fitdown-style) or json")
+}
From d770092ce3e362bed634872cec2d6e947ffd15fb Mon Sep 17 00:00:00 2001
From: DTTerastar
Date: Mon, 8 Jun 2026 18:55:46 -0400
Subject: [PATCH 2/8] ci: cross-compile review binaries as artifacts (darwin +
linux)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
Co-authored-by: Orca
---
.github/workflows/ci.yml | 29 +++++++++++++++++++++++++++++
1 file changed, 29 insertions(+)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 8779909..8498c22 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -45,3 +45,32 @@ jobs:
# Path the compat-tagged test reads from os.Getenv.
LIFTOFF_EXPORT_BIN: /tmp/liftoff-export
run: go test -tags=compat -run TestContractFormats ./...
+
+ artifacts:
+ # Cross-compile review binaries for the platforms reviewers actually use.
+ # Goreleaser owns tagged releases; this job exists so a PR's diff can be
+ # tried end-to-end without a local Go toolchain — download from the run
+ # page, chmod +x, run. Retention is short (14d) to keep storage tidy.
+ name: review binaries
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-go@v5
+ with:
+ go-version-file: go.mod
+ cache: true
+ - name: cross-compile
+ run: |
+ mkdir -p dist
+ for target in darwin/arm64 darwin/amd64 linux/amd64 linux/arm64; do
+ os="${target%/*}"; arch="${target#*/}"
+ GOOS=$os GOARCH=$arch go build -trimpath -ldflags="-s -w" \
+ -o "dist/liftoff-export-${os}-${arch}" .
+ done
+ ls -lh dist/
+ - uses: actions/upload-artifact@v4
+ with:
+ name: liftoff-export-${{ github.sha }}
+ path: dist/
+ retention-days: 14
+ if-no-files-found: error
From 9825c21795fe4c0f33c619222874a2a852097fdd Mon Sep 17 00:00:00 2001
From: DTTerastar
Date: Mon, 8 Jun 2026 19:06:16 -0400
Subject: [PATCH 3/8] ci: drop sha from artifact name (already scoped per run)
Co-Authored-By: Claude Opus 4.7
Co-authored-by: Orca
---
.github/workflows/ci.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 8498c22..083d5dc 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -70,7 +70,7 @@ jobs:
ls -lh dist/
- uses: actions/upload-artifact@v4
with:
- name: liftoff-export-${{ github.sha }}
+ name: liftoff-export
path: dist/
retention-days: 14
if-no-files-found: error
From d5e29cf7d669384f354897f72b76b6f1e78df531 Mon Sep 17 00:00:00 2001
From: DTTerastar
Date: Mon, 8 Jun 2026 19:11:10 -0400
Subject: [PATCH 4/8] feat(routines): use markdown H1 + hr between routines for
scannability
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
Co-authored-by: Orca
---
cmd/routines.go | 10 ++++++----
1 file changed, 6 insertions(+), 4 deletions(-)
diff --git a/cmd/routines.go b/cmd/routines.go
index cd04837..a35556e 100644
--- a/cmd/routines.go
+++ b/cmd/routines.go
@@ -167,11 +167,16 @@ func pickPreset(presets []Preset, arg string) (*Preset, error) {
// exercise name so cues like "Left Only" aren't lost.
func printRoutinesFitdown(presets []Preset) error {
for i, p := range presets {
+ if i > 0 {
+ fmt.Println()
+ fmt.Println("---")
+ fmt.Println()
+ }
star := ""
if p.IsFavorite {
star = " ★"
}
- fmt.Printf("Routine %s%s\n", p.Name, star)
+ fmt.Printf("# Routine: %s%s\n", p.Name, star)
for _, ex := range p.ExerciseData {
fmt.Println()
fmt.Println(ex.ExerciseName)
@@ -196,9 +201,6 @@ func printRoutinesFitdown(presets []Preset) error {
i = j
}
}
- if i < len(presets)-1 {
- fmt.Println()
- }
}
return nil
}
From f640c34ddf13b62e7e19aed3010502ae073cfb1d Mon Sep 17 00:00:00 2001
From: DTTerastar
Date: Mon, 8 Jun 2026 19:13:07 -0400
Subject: [PATCH 5/8] feat(routines): render exerciseNotes inline in parens,
not as a markdown #
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
"# 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
Co-authored-by: Orca
---
cmd/routines.go | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/cmd/routines.go b/cmd/routines.go
index a35556e..ed245c2 100644
--- a/cmd/routines.go
+++ b/cmd/routines.go
@@ -179,10 +179,11 @@ func printRoutinesFitdown(presets []Preset) error {
fmt.Printf("# Routine: %s%s\n", p.Name, star)
for _, ex := range p.ExerciseData {
fmt.Println()
- fmt.Println(ex.ExerciseName)
+ name := ex.ExerciseName
if ex.ExerciseNotes != nil && *ex.ExerciseNotes != "" {
- fmt.Printf("# %s\n", *ex.ExerciseNotes)
+ name = fmt.Sprintf("%s (%s)", name, *ex.ExerciseNotes)
}
+ fmt.Println(name)
var lines []string
for _, s := range ex.SetsData {
lines = append(lines, fitdownSetLine(ex.ExerciseTypes, s))
From 2d863cc9f17fc69baf79adc27a604f04da4c90ed Mon Sep 17 00:00:00 2001
From: DTTerastar
Date: Mon, 8 Jun 2026 19:16:01 -0400
Subject: [PATCH 6/8] feat(workouts): use parens for SessionNotes + render
ExerciseNotes
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
Co-authored-by: Orca
---
cmd/routines.go | 7 ++++---
cmd/workouts.go | 15 ++++++++++-----
2 files changed, 14 insertions(+), 8 deletions(-)
diff --git a/cmd/routines.go b/cmd/routines.go
index ed245c2..e671a5f 100644
--- a/cmd/routines.go
+++ b/cmd/routines.go
@@ -162,9 +162,10 @@ func pickPreset(presets []Preset, arg string) (*Preset, error) {
}
// printRoutinesFitdown renders presets in the same fitdown notation
-// `workouts list` uses. Each routine gets a header; favorite routines are
-// marked with a star; exerciseNotes per-exercise are surfaced below the
-// exercise name so cues like "Left Only" aren't lost.
+// `workouts list` uses. Each routine gets a markdown H1 header; favorite
+// routines are marked with a star; per-exercise notes are appended to the
+// exercise name in parens so cues like "Left Only" aren't lost without
+// colliding with markdown heading semantics.
func printRoutinesFitdown(presets []Preset) error {
for i, p := range presets {
if i > 0 {
diff --git a/cmd/workouts.go b/cmd/workouts.go
index e15d260..d43cf79 100644
--- a/cmd/workouts.go
+++ b/cmd/workouts.go
@@ -281,20 +281,25 @@ func filterExercises(posts []Post, pattern string) []Post {
func printFitdown(posts []Post) error {
for i, post := range posts {
+ var header string
t, err := time.Parse(time.RFC3339Nano, post.StartedAt)
if err != nil {
- fmt.Printf("Workout %s\n", post.StartedAt)
+ header = fmt.Sprintf("Workout %s", post.StartedAt)
} else {
- fmt.Printf("Workout %s\n", t.Local().Format("January 2, 2006"))
+ header = fmt.Sprintf("Workout %s", t.Local().Format("January 2, 2006"))
}
-
if post.SessionNotes != "" {
- fmt.Printf("# %s\n", post.SessionNotes)
+ header = fmt.Sprintf("%s (%s)", header, post.SessionNotes)
}
+ fmt.Println(header)
for _, ex := range post.ExerciseData {
fmt.Println()
- fmt.Println(ex.ExerciseName)
+ name := ex.ExerciseName
+ if ex.ExerciseNotes != "" {
+ name = fmt.Sprintf("%s (%s)", name, ex.ExerciseNotes)
+ }
+ fmt.Println(name)
var lines []string
for _, s := range ex.SetsData {
From dde79abf491fd5dc42405ba8f3b2f493b9acf8c0 Mon Sep 17 00:00:00 2001
From: DTTerastar
Date: Mon, 8 Jun 2026 19:19:49 -0400
Subject: [PATCH 7/8] 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
Co-authored-by: Orca
---
cmd/routines.go | 25 ++++-----
cmd/routines_test.go | 121 +++++++++++++++++++++++++++++++++++++++++++
cmd/workouts.go | 17 +++---
cmd/workouts_test.go | 99 +++++++++++++++++++++++++++++++++++
4 files changed, 244 insertions(+), 18 deletions(-)
create mode 100644 cmd/routines_test.go
create mode 100644 cmd/workouts_test.go
diff --git a/cmd/routines.go b/cmd/routines.go
index e671a5f..67f6ab8 100644
--- a/cmd/routines.go
+++ b/cmd/routines.go
@@ -3,6 +3,7 @@ package cmd
import (
"encoding/json"
"fmt"
+ "io"
"os"
"strings"
@@ -90,7 +91,7 @@ var routinesListCmd = &cobra.Command{
if format == "json" {
return printJSON(presets)
}
- return printRoutinesFitdown(presets)
+ return renderRoutinesFitdown(os.Stdout, presets)
},
}
@@ -116,7 +117,7 @@ var routinesShowCmd = &cobra.Command{
if format == "json" {
return printJSON([]Preset{*match})
}
- return printRoutinesFitdown([]Preset{*match})
+ return renderRoutinesFitdown(os.Stdout, []Preset{*match})
},
}
@@ -161,30 +162,30 @@ func pickPreset(presets []Preset, arg string) (*Preset, error) {
return nil, fmt.Errorf("no routine matches %q", arg)
}
-// printRoutinesFitdown renders presets in the same fitdown notation
+// renderRoutinesFitdown renders presets in the same fitdown notation
// `workouts list` uses. Each routine gets a markdown H1 header; favorite
// routines are marked with a star; per-exercise notes are appended to the
// exercise name in parens so cues like "Left Only" aren't lost without
// colliding with markdown heading semantics.
-func printRoutinesFitdown(presets []Preset) error {
+func renderRoutinesFitdown(w io.Writer, presets []Preset) error {
for i, p := range presets {
if i > 0 {
- fmt.Println()
- fmt.Println("---")
- fmt.Println()
+ fmt.Fprintln(w)
+ fmt.Fprintln(w, "---")
+ fmt.Fprintln(w)
}
star := ""
if p.IsFavorite {
star = " ★"
}
- fmt.Printf("# Routine: %s%s\n", p.Name, star)
+ fmt.Fprintf(w, "# Routine: %s%s\n", p.Name, star)
for _, ex := range p.ExerciseData {
- fmt.Println()
+ fmt.Fprintln(w)
name := ex.ExerciseName
if ex.ExerciseNotes != nil && *ex.ExerciseNotes != "" {
name = fmt.Sprintf("%s (%s)", name, *ex.ExerciseNotes)
}
- fmt.Println(name)
+ fmt.Fprintln(w, name)
var lines []string
for _, s := range ex.SetsData {
lines = append(lines, fitdownSetLine(ex.ExerciseTypes, s))
@@ -196,9 +197,9 @@ func printRoutinesFitdown(presets []Preset) error {
j++
}
if n := j - i; n > 1 {
- fmt.Printf("%dx%s\n", n, lines[i])
+ fmt.Fprintf(w, "%dx%s\n", n, lines[i])
} else {
- fmt.Println(lines[i])
+ fmt.Fprintln(w, lines[i])
}
i = j
}
diff --git a/cmd/routines_test.go b/cmd/routines_test.go
new file mode 100644
index 0000000..9fbcf8c
--- /dev/null
+++ b/cmd/routines_test.go
@@ -0,0 +1,121 @@
+package cmd
+
+import (
+ "bytes"
+ "encoding/json"
+ "strings"
+ "testing"
+)
+
+func stringPtr(s string) *string { return &s }
+
+// Smallest possible preset — just a name, no exercises. Used by the
+// header/separator tests where exercise rendering is incidental.
+func bareRoutine(name string) Preset {
+ return Preset{Name: name, ExerciseData: []PresetExerciseData{}}
+}
+
+func TestRenderRoutinesFitdown_HeadingAndSeparator(t *testing.T) {
+ var buf bytes.Buffer
+ if err := renderRoutinesFitdown(&buf, []Preset{bareRoutine("Push"), bareRoutine("Pull")}); err != nil {
+ t.Fatal(err)
+ }
+ out := buf.String()
+ if !strings.Contains(out, "# Routine: Push") {
+ t.Errorf("missing H1 for first routine; got:\n%s", out)
+ }
+ if !strings.Contains(out, "# Routine: Pull") {
+ t.Errorf("missing H1 for second routine; got:\n%s", out)
+ }
+ if !strings.Contains(out, "\n---\n") {
+ t.Errorf("missing --- horizontal rule between routines; got:\n%s", out)
+ }
+ if strings.HasSuffix(strings.TrimRight(out, "\n"), "---") {
+ t.Errorf("trailing --- should not appear after last routine; got:\n%s", out)
+ }
+}
+
+func TestRenderRoutinesFitdown_SingleRoutineNoSeparator(t *testing.T) {
+ var buf bytes.Buffer
+ renderRoutinesFitdown(&buf, []Preset{bareRoutine("Push")})
+ if strings.Contains(buf.String(), "---") {
+ t.Errorf("single routine should have no --- separator; got:\n%s", buf.String())
+ }
+}
+
+func TestRenderRoutinesFitdown_FavoriteStar(t *testing.T) {
+ p := bareRoutine("Push")
+ p.IsFavorite = true
+ var buf bytes.Buffer
+ renderRoutinesFitdown(&buf, []Preset{p})
+ if !strings.Contains(buf.String(), "# Routine: Push ★") {
+ t.Errorf("favorite routine should be starred; got:\n%s", buf.String())
+ }
+}
+
+func TestRenderRoutinesFitdown_ExerciseNoteInParens(t *testing.T) {
+ p := Preset{
+ Name: "Test",
+ ExerciseData: []PresetExerciseData{{
+ ExerciseName: "Kettlebell Swing",
+ ExerciseTypes: "WR",
+ ExerciseNotes: stringPtr("Left Only"),
+ SetsData: []PresetSetData{{
+ InputOne: json.Number("30"),
+ InputTwo: json.Number("10"),
+ }},
+ }},
+ }
+ var buf bytes.Buffer
+ renderRoutinesFitdown(&buf, []Preset{p})
+ out := buf.String()
+ if !strings.Contains(out, "Kettlebell Swing (Left Only)") {
+ t.Errorf("exercise note should render in parens after name; got:\n%s", out)
+ }
+ // Guard against the old "# Left Only" rendering (markdown H1 collision).
+ if strings.Contains(out, "\n# Left Only") {
+ t.Errorf("exercise note should NOT render as markdown H1; got:\n%s", out)
+ }
+}
+
+func TestRenderRoutinesFitdown_SetCompression(t *testing.T) {
+ mkSet := func() PresetSetData {
+ return PresetSetData{InputOne: json.Number("100"), InputTwo: json.Number("5")}
+ }
+ p := Preset{
+ Name: "Test",
+ ExerciseData: []PresetExerciseData{{
+ ExerciseName: "Squat",
+ ExerciseTypes: "WR",
+ SetsData: []PresetSetData{mkSet(), mkSet(), mkSet()},
+ }},
+ }
+ var buf bytes.Buffer
+ renderRoutinesFitdown(&buf, []Preset{p})
+ if !strings.Contains(buf.String(), "3x5@100") {
+ t.Errorf("consecutive identical sets should compress to Nx notation; got:\n%s", buf.String())
+ }
+}
+
+func TestPickPreset(t *testing.T) {
+ presets := []Preset{
+ {ID: "id-push", Name: "Push"},
+ {ID: "id-pull", Name: "Pull"},
+ {ID: "id-pull2", Name: "Pull"}, // collision
+ }
+ if p, err := pickPreset(presets, "Push"); err != nil || p.ID != "id-push" {
+ t.Errorf("exact name match: got %+v, err=%v", p, err)
+ }
+ if p, err := pickPreset(presets, "push"); err != nil || p.ID != "id-push" {
+ t.Errorf("case-insensitive name match: got %+v, err=%v", p, err)
+ }
+ if p, err := pickPreset(presets, "id-push"); err != nil || p.ID != "id-push" {
+ t.Errorf("id match: got %+v, err=%v", p, err)
+ }
+ if _, err := pickPreset(presets, "Pull"); err == nil {
+ t.Errorf("colliding name should error to force disambiguation by id")
+ }
+ if _, err := pickPreset(presets, "nope"); err == nil {
+ t.Errorf("missing name+id should error")
+ }
+}
diff --git a/cmd/workouts.go b/cmd/workouts.go
index d43cf79..c813015 100644
--- a/cmd/workouts.go
+++ b/cmd/workouts.go
@@ -3,6 +3,7 @@ package cmd
import (
"encoding/json"
"fmt"
+ "io"
"os"
"reflect"
"strings"
@@ -280,6 +281,10 @@ func filterExercises(posts []Post, pattern string) []Post {
}
func printFitdown(posts []Post) error {
+ return renderWorkoutsFitdown(os.Stdout, posts)
+}
+
+func renderWorkoutsFitdown(w io.Writer, posts []Post) error {
for i, post := range posts {
var header string
t, err := time.Parse(time.RFC3339Nano, post.StartedAt)
@@ -291,15 +296,15 @@ func printFitdown(posts []Post) error {
if post.SessionNotes != "" {
header = fmt.Sprintf("%s (%s)", header, post.SessionNotes)
}
- fmt.Println(header)
+ fmt.Fprintln(w, header)
for _, ex := range post.ExerciseData {
- fmt.Println()
+ fmt.Fprintln(w)
name := ex.ExerciseName
if ex.ExerciseNotes != "" {
name = fmt.Sprintf("%s (%s)", name, ex.ExerciseNotes)
}
- fmt.Println(name)
+ fmt.Fprintln(w, name)
var lines []string
for _, s := range ex.SetsData {
@@ -334,16 +339,16 @@ func printFitdown(posts []Post) error {
j++
}
if n := j - i; n > 1 {
- fmt.Printf("%dx%s\n", n, lines[i])
+ fmt.Fprintf(w, "%dx%s\n", n, lines[i])
} else {
- fmt.Println(lines[i])
+ fmt.Fprintln(w, lines[i])
}
i = j
}
}
if i < len(posts)-1 {
- fmt.Println()
+ fmt.Fprintln(w)
}
}
return nil
diff --git a/cmd/workouts_test.go b/cmd/workouts_test.go
new file mode 100644
index 0000000..55a1d3b
--- /dev/null
+++ b/cmd/workouts_test.go
@@ -0,0 +1,99 @@
+package cmd
+
+import (
+ "bytes"
+ "encoding/json"
+ "strings"
+ "testing"
+)
+
+// A workout the renderer hasn't been asked to apologize for: real
+// timestamp, one exercise, one set. Used as the baseline; specific tests
+// mutate one field at a time.
+func bareWorkout() Post {
+ return Post{
+ StartedAt: "2026-06-08T12:00:00Z",
+ ExerciseData: []ExerciseData{{
+ ExerciseName: "Bench Press",
+ ExerciseTypes: "WR",
+ SetsData: []SetData{{
+ InputOne: json.Number("100"),
+ InputTwo: json.Number("5"),
+ }},
+ }},
+ }
+}
+
+func TestRenderWorkoutsFitdown_NoNotes(t *testing.T) {
+ var buf bytes.Buffer
+ renderWorkoutsFitdown(&buf, []Post{bareWorkout()})
+ out := buf.String()
+ if !strings.HasPrefix(out, "Workout ") {
+ t.Errorf("expected Workout header prefix; got:\n%s", out)
+ }
+ if !strings.Contains(out, "Bench Press\n5@100") {
+ t.Errorf("expected 'Bench Press' + set line; got:\n%s", out)
+ }
+}
+
+func TestRenderWorkoutsFitdown_SessionNoteInParens(t *testing.T) {
+ w := bareWorkout()
+ w.SessionNotes = "felt great"
+ var buf bytes.Buffer
+ renderWorkoutsFitdown(&buf, []Post{w})
+ out := buf.String()
+ if !strings.Contains(out, "(felt great)") {
+ t.Errorf("session note should append in parens to header; got:\n%s", out)
+ }
+ // Guard against the old "# felt great" rendering (markdown H1 collision).
+ if strings.Contains(out, "\n# felt great") || strings.HasPrefix(out, "# felt great") {
+ t.Errorf("session note should NOT render as markdown H1; got:\n%s", out)
+ }
+}
+
+func TestRenderWorkoutsFitdown_ExerciseNoteInParens(t *testing.T) {
+ w := bareWorkout()
+ w.ExerciseData[0].ExerciseName = "Machine Seated Crunch"
+ w.ExerciseData[0].ExerciseNotes = "Seat 3"
+ var buf bytes.Buffer
+ renderWorkoutsFitdown(&buf, []Post{w})
+ out := buf.String()
+ if !strings.Contains(out, "Machine Seated Crunch (Seat 3)") {
+ t.Errorf("exercise note should render in parens after name; got:\n%s", out)
+ }
+ if strings.Contains(out, "\n# Seat 3") {
+ t.Errorf("exercise note should NOT render as markdown H1; got:\n%s", out)
+ }
+}
+
+// Set-line notation hasn't changed in this PR but is the load-bearing
+// piece routines share with workouts (via the fitdownSetLine duplicate).
+// One representative case per ExerciseType so a regression in either
+// renderer surfaces in CI.
+func TestRenderWorkoutsFitdown_SetNotation(t *testing.T) {
+ cases := []struct {
+ exType string
+ one, two string
+ want string
+ }{
+ {"WR", "100", "5", "5@100"}, // weight/reps
+ {"BR", "0", "10", "10@+0"}, // bodyweight reps
+ {"AB", "20", "10", "10@-20"}, // assisted bodyweight (minus)
+ {"ND", "0", "495", "8:15"}, // no-data duration
+ }
+ for _, c := range cases {
+ t.Run(c.exType, func(t *testing.T) {
+ w := bareWorkout()
+ w.ExerciseData[0].ExerciseTypes = c.exType
+ w.ExerciseData[0].SetsData[0] = SetData{
+ InputOne: json.Number(c.one),
+ InputTwo: json.Number(c.two),
+ }
+ var buf bytes.Buffer
+ renderWorkoutsFitdown(&buf, []Post{w})
+ if !strings.Contains(buf.String(), c.want) {
+ t.Errorf("%s: expected %q in output; got:\n%s", c.exType, c.want, buf.String())
+ }
+ })
+ }
+}
From 0ad1eabd4669dfea8d16e58537a14b7e1cef8424 Mon Sep 17 00:00:00 2001
From: DTTerastar
Date: Mon, 15 Jun 2026 00:29:37 -0400
Subject: [PATCH 8/8] 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
Co-authored-by: Orca
---
cmd/routines.go | 171 +++++++++++++++++++++++++++++--------------
cmd/routines_test.go | 136 +++++++++++++++++++++++++++-------
2 files changed, 229 insertions(+), 78 deletions(-)
diff --git a/cmd/routines.go b/cmd/routines.go
index 67f6ab8..553d5bd 100644
--- a/cmd/routines.go
+++ b/cmd/routines.go
@@ -56,15 +56,32 @@ type PresetSetData struct {
InputTwo json.Number `json:"inputTwo"`
}
+// Folder is a Liftoff routine folder — a labeled group of presets in the
+// app's Routines tab. The nested Presets array contains the full Preset
+// objects (each with folderId pointing back to this folder).
+type Folder struct {
+ ID string `json:"id"`
+ CreatedAt string `json:"createdAt"`
+ UserID string `json:"userId"`
+ Name string `json:"name"`
+ PresetsOrder []string `json:"presetsOrder"`
+ Presets []Preset `json:"presets"`
+}
+
// presetsResponse is the top-level shape of fitnessService.fetchUserPresetsWithFolders.
-// We currently surface only presetsWithoutFolder; folders trigger a stderr
-// warning so a non-empty value isn't silently dropped. Folder support is a
-// follow-up once we have a non-empty example to model.
+// PresetsWithoutFolder are what the Liftoff app surfaces under "My Routines"
+// (the implicit ungrouped section); folder Presets are surfaced under their
+// folder name. Both rendering paths share the same Preset shape.
type presetsResponse struct {
- Folders json.RawMessage `json:"folders"`
- PresetsWithoutFolder []Preset `json:"presetsWithoutFolder"`
+ Folders []Folder `json:"folders"`
+ PresetsWithoutFolder []Preset `json:"presetsWithoutFolder"`
}
+// unfiledLabel is the section title we use for PresetsWithoutFolder in
+// markdown output. Matches the Liftoff app's UI label so a user comparing
+// terminal output to the app sees the same heading.
+const unfiledLabel = "My Routines"
+
var routinesCmd = &cobra.Command{
Use: "routines",
Short: "Saved workout routine (preset) commands",
@@ -84,14 +101,14 @@ var routinesListCmd = &cobra.Command{
if err != nil {
return err
}
- presets, err := fetchPresets()
+ resp, err := fetchPresets()
if err != nil {
return err
}
if format == "json" {
- return printJSON(presets)
+ return printJSON(resp)
}
- return renderRoutinesFitdown(os.Stdout, presets)
+ return renderRoutinesFitdown(os.Stdout, resp)
},
}
@@ -106,37 +123,58 @@ var routinesShowCmd = &cobra.Command{
if err != nil {
return err
}
- presets, err := fetchPresets()
+ resp, err := fetchPresets()
if err != nil {
return err
}
- match, err := pickPreset(presets, args[0])
+ match, err := pickPreset(resp, args[0])
if err != nil {
return err
}
if format == "json" {
return printJSON([]Preset{*match})
}
- return renderRoutinesFitdown(os.Stdout, []Preset{*match})
+ // `show` emits a single routine without any section heading —
+ // the routine name is enough context for a one-off lookup.
+ return renderOnePreset(os.Stdout, *match, "#")
},
}
-func fetchPresets() ([]Preset, error) {
+func fetchPresets() (*presetsResponse, error) {
c := client.New()
var resp presetsResponse
if err := c.Query("fitnessService.fetchUserPresetsWithFolders", nil, &resp); err != nil {
return nil, err
}
- if len(resp.Folders) > 0 && string(resp.Folders) != "[]" && string(resp.Folders) != "null" {
- fmt.Fprintln(os.Stderr, "liftoff-export: warning — routine folders detected but not yet rendered; presets outside folders will be shown. File a follow-up if you need folder support.")
+ // Normalize nil → empty slice so --format json emits `[]` not `null`,
+ // matching what the upstream API does on an empty account.
+ if resp.Folders == nil {
+ resp.Folders = []Folder{}
+ }
+ if resp.PresetsWithoutFolder == nil {
+ resp.PresetsWithoutFolder = []Preset{}
+ }
+ return &resp, nil
+}
+
+// allPresets flattens both unfiled and foldered presets into one slice for
+// lookup. Used by pickPreset so `routines show "Valley Creek 1"` finds a
+// routine regardless of whether it lives in a folder.
+func allPresets(resp *presetsResponse) []Preset {
+ out := make([]Preset, 0, len(resp.PresetsWithoutFolder))
+ out = append(out, resp.PresetsWithoutFolder...)
+ for _, f := range resp.Folders {
+ out = append(out, f.Presets...)
}
- return resp.PresetsWithoutFolder, nil
+ return out
}
-// pickPreset resolves a `show` argument to exactly one preset. Match order:
-// (1) case-insensitive exact name match, (2) exact id match. Multiple
-// name-matches return an error so the caller can disambiguate by id.
-func pickPreset(presets []Preset, arg string) (*Preset, error) {
+// pickPreset resolves a `show` argument to exactly one preset across the
+// full account (unfiled + foldered). Match order: (1) case-insensitive
+// exact name match, (2) exact id match. Multiple name-matches return an
+// error so the caller can disambiguate by id.
+func pickPreset(resp *presetsResponse, arg string) (*Preset, error) {
+ presets := allPresets(resp)
target := strings.ToLower(strings.TrimSpace(arg))
var nameHits []Preset
for _, p := range presets {
@@ -162,47 +200,74 @@ func pickPreset(presets []Preset, arg string) (*Preset, error) {
return nil, fmt.Errorf("no routine matches %q", arg)
}
-// renderRoutinesFitdown renders presets in the same fitdown notation
-// `workouts list` uses. Each routine gets a markdown H1 header; favorite
-// routines are marked with a star; per-exercise notes are appended to the
-// exercise name in parens so cues like "Left Only" aren't lost without
-// colliding with markdown heading semantics.
-func renderRoutinesFitdown(w io.Writer, presets []Preset) error {
- for i, p := range presets {
- if i > 0 {
+// renderRoutinesFitdown renders the full account: a "# My Routines" H1 with
+// each unfiled preset as an H2 underneath, then "# " H1 per
+// folder with its foldered presets as H2. "---" rules separate sibling
+// routines and section boundaries. Favorite routines are marked with a
+// star; per-exercise notes are appended in parens so cues like "Left Only"
+// aren't rendered as a markdown heading by mistake.
+func renderRoutinesFitdown(w io.Writer, resp *presetsResponse) error {
+ first := true
+ emitSection := func(heading string, presets []Preset) {
+ if len(presets) == 0 {
+ return
+ }
+ if !first {
fmt.Fprintln(w)
fmt.Fprintln(w, "---")
fmt.Fprintln(w)
}
- star := ""
- if p.IsFavorite {
- star = " ★"
- }
- fmt.Fprintf(w, "# Routine: %s%s\n", p.Name, star)
- for _, ex := range p.ExerciseData {
- fmt.Fprintln(w)
- name := ex.ExerciseName
- if ex.ExerciseNotes != nil && *ex.ExerciseNotes != "" {
- name = fmt.Sprintf("%s (%s)", name, *ex.ExerciseNotes)
+ first = false
+ fmt.Fprintf(w, "# %s\n", heading)
+ for i, p := range presets {
+ if i > 0 {
+ fmt.Fprintln(w)
+ fmt.Fprintln(w, "---")
}
- fmt.Fprintln(w, name)
- var lines []string
- for _, s := range ex.SetsData {
- lines = append(lines, fitdownSetLine(ex.ExerciseTypes, s))
+ fmt.Fprintln(w)
+ renderOnePreset(w, p, "##")
+ }
+ }
+ emitSection(unfiledLabel, resp.PresetsWithoutFolder)
+ for _, f := range resp.Folders {
+ emitSection(f.Name, f.Presets)
+ }
+ return nil
+}
+
+// renderOnePreset prints a single preset under the given heading marker
+// (e.g. "##" inside a folder section, "#" for `routines show` where the
+// routine has no enclosing section). Body format (exercises, set lines,
+// note parens) is identical regardless of caller.
+func renderOnePreset(w io.Writer, p Preset, headingMarker string) error {
+ star := ""
+ if p.IsFavorite {
+ star = " ★"
+ }
+ fmt.Fprintf(w, "%s Routine: %s%s\n", headingMarker, p.Name, star)
+ for _, ex := range p.ExerciseData {
+ fmt.Fprintln(w)
+ name := ex.ExerciseName
+ if ex.ExerciseNotes != nil && *ex.ExerciseNotes != "" {
+ name = fmt.Sprintf("%s (%s)", name, *ex.ExerciseNotes)
+ }
+ fmt.Fprintln(w, name)
+ var lines []string
+ for _, s := range ex.SetsData {
+ lines = append(lines, fitdownSetLine(ex.ExerciseTypes, s))
+ }
+ // Compress consecutive identical lines into Nx... notation.
+ for i := 0; i < len(lines); {
+ j := i + 1
+ for j < len(lines) && lines[j] == lines[i] {
+ j++
}
- // Compress consecutive identical lines into Nx... notation
- for i := 0; i < len(lines); {
- j := i + 1
- for j < len(lines) && lines[j] == lines[i] {
- j++
- }
- if n := j - i; n > 1 {
- fmt.Fprintf(w, "%dx%s\n", n, lines[i])
- } else {
- fmt.Fprintln(w, lines[i])
- }
- i = j
+ if n := j - i; n > 1 {
+ fmt.Fprintf(w, "%dx%s\n", n, lines[i])
+ } else {
+ fmt.Fprintln(w, lines[i])
}
+ i = j
}
}
return nil
diff --git a/cmd/routines_test.go b/cmd/routines_test.go
index 9fbcf8c..f10f852 100644
--- a/cmd/routines_test.go
+++ b/cmd/routines_test.go
@@ -15,20 +15,30 @@ func bareRoutine(name string) Preset {
return Preset{Name: name, ExerciseData: []PresetExerciseData{}}
}
-func TestRenderRoutinesFitdown_HeadingAndSeparator(t *testing.T) {
+func unfiledOnly(presets ...Preset) *presetsResponse {
+ return &presetsResponse{
+ Folders: []Folder{},
+ PresetsWithoutFolder: presets,
+ }
+}
+
+func TestRenderRoutinesFitdown_UnfiledSectionHeading(t *testing.T) {
var buf bytes.Buffer
- if err := renderRoutinesFitdown(&buf, []Preset{bareRoutine("Push"), bareRoutine("Pull")}); err != nil {
+ if err := renderRoutinesFitdown(&buf, unfiledOnly(bareRoutine("Push"), bareRoutine("Pull"))); err != nil {
t.Fatal(err)
}
out := buf.String()
- if !strings.Contains(out, "# Routine: Push") {
- t.Errorf("missing H1 for first routine; got:\n%s", out)
+ if !strings.Contains(out, "# My Routines") {
+ t.Errorf("missing '# My Routines' section heading; got:\n%s", out)
+ }
+ if !strings.Contains(out, "## Routine: Push") {
+ t.Errorf("routine should render as H2 inside a section; got:\n%s", out)
}
- if !strings.Contains(out, "# Routine: Pull") {
- t.Errorf("missing H1 for second routine; got:\n%s", out)
+ if !strings.Contains(out, "## Routine: Pull") {
+ t.Errorf("missing second routine; got:\n%s", out)
}
if !strings.Contains(out, "\n---\n") {
- t.Errorf("missing --- horizontal rule between routines; got:\n%s", out)
+ t.Errorf("missing --- horizontal rule between sibling routines; got:\n%s", out)
}
if strings.HasSuffix(strings.TrimRight(out, "\n"), "---") {
t.Errorf("trailing --- should not appear after last routine; got:\n%s", out)
@@ -37,7 +47,7 @@ func TestRenderRoutinesFitdown_HeadingAndSeparator(t *testing.T) {
func TestRenderRoutinesFitdown_SingleRoutineNoSeparator(t *testing.T) {
var buf bytes.Buffer
- renderRoutinesFitdown(&buf, []Preset{bareRoutine("Push")})
+ renderRoutinesFitdown(&buf, unfiledOnly(bareRoutine("Push")))
if strings.Contains(buf.String(), "---") {
t.Errorf("single routine should have no --- separator; got:\n%s", buf.String())
}
@@ -47,12 +57,64 @@ func TestRenderRoutinesFitdown_FavoriteStar(t *testing.T) {
p := bareRoutine("Push")
p.IsFavorite = true
var buf bytes.Buffer
- renderRoutinesFitdown(&buf, []Preset{p})
- if !strings.Contains(buf.String(), "# Routine: Push ★") {
+ renderRoutinesFitdown(&buf, unfiledOnly(p))
+ if !strings.Contains(buf.String(), "## Routine: Push ★") {
t.Errorf("favorite routine should be starred; got:\n%s", buf.String())
}
}
+func TestRenderRoutinesFitdown_FolderHeadingAndH2Routines(t *testing.T) {
+ resp := &presetsResponse{
+ PresetsWithoutFolder: []Preset{bareRoutine("Free Weights")},
+ Folders: []Folder{{
+ Name: "Valley Creek",
+ Presets: []Preset{bareRoutine("Valley Creek 1"), bareRoutine("Valley Creek 1 Copy")},
+ }},
+ }
+ var buf bytes.Buffer
+ if err := renderRoutinesFitdown(&buf, resp); err != nil {
+ t.Fatal(err)
+ }
+ out := buf.String()
+ if !strings.Contains(out, "# My Routines") {
+ t.Errorf("missing unfiled section heading; got:\n%s", out)
+ }
+ if !strings.Contains(out, "# Valley Creek") {
+ t.Errorf("folder name should render as H1; got:\n%s", out)
+ }
+ if !strings.Contains(out, "## Routine: Valley Creek 1") {
+ t.Errorf("foldered routine should render as H2; got:\n%s", out)
+ }
+ // Unfiled section must come before folder sections so output order
+ // matches the Liftoff app's UI (ungrouped on top).
+ unfiledIdx := strings.Index(out, "# My Routines")
+ folderIdx := strings.Index(out, "# Valley Creek")
+ if unfiledIdx == -1 || folderIdx == -1 || unfiledIdx > folderIdx {
+ t.Errorf("unfiled section must come before folder section; got:\n%s", out)
+ }
+}
+
+func TestRenderRoutinesFitdown_EmptySectionSuppressed(t *testing.T) {
+ // Account with only a folder — no unfiled presets. The "# My Routines"
+ // heading must not appear over an empty section.
+ resp := &presetsResponse{
+ PresetsWithoutFolder: []Preset{},
+ Folders: []Folder{{
+ Name: "Solo",
+ Presets: []Preset{bareRoutine("Only One")},
+ }},
+ }
+ var buf bytes.Buffer
+ renderRoutinesFitdown(&buf, resp)
+ out := buf.String()
+ if strings.Contains(out, "# My Routines") {
+ t.Errorf("empty unfiled section should not emit a heading; got:\n%s", out)
+ }
+ if !strings.Contains(out, "# Solo") {
+ t.Errorf("folder heading missing; got:\n%s", out)
+ }
+}
+
func TestRenderRoutinesFitdown_ExerciseNoteInParens(t *testing.T) {
p := Preset{
Name: "Test",
@@ -67,7 +129,7 @@ func TestRenderRoutinesFitdown_ExerciseNoteInParens(t *testing.T) {
}},
}
var buf bytes.Buffer
- renderRoutinesFitdown(&buf, []Preset{p})
+ renderRoutinesFitdown(&buf, unfiledOnly(p))
out := buf.String()
if !strings.Contains(out, "Kettlebell Swing (Left Only)") {
t.Errorf("exercise note should render in parens after name; got:\n%s", out)
@@ -91,31 +153,55 @@ func TestRenderRoutinesFitdown_SetCompression(t *testing.T) {
}},
}
var buf bytes.Buffer
- renderRoutinesFitdown(&buf, []Preset{p})
+ renderRoutinesFitdown(&buf, unfiledOnly(p))
if !strings.Contains(buf.String(), "3x5@100") {
t.Errorf("consecutive identical sets should compress to Nx notation; got:\n%s", buf.String())
}
}
+func TestRenderOnePreset_ShowUsesH1(t *testing.T) {
+ // `routines show` doesn't render inside a section, so the lone routine
+ // gets an H1 ("# Routine: NAME") rather than the H2 used in list output.
+ var buf bytes.Buffer
+ renderOnePreset(&buf, bareRoutine("Push"), "#")
+ if !strings.Contains(buf.String(), "# Routine: Push") {
+ t.Errorf("show should use H1 marker; got:\n%s", buf.String())
+ }
+ if strings.Contains(buf.String(), "## Routine: Push") {
+ t.Errorf("show should NOT use H2 marker; got:\n%s", buf.String())
+ }
+}
+
func TestPickPreset(t *testing.T) {
- presets := []Preset{
- {ID: "id-push", Name: "Push"},
- {ID: "id-pull", Name: "Pull"},
- {ID: "id-pull2", Name: "Pull"}, // collision
+ resp := &presetsResponse{
+ PresetsWithoutFolder: []Preset{
+ {ID: "id-push", Name: "Push"},
+ {ID: "id-pull", Name: "Pull"},
+ },
+ Folders: []Folder{{
+ Name: "Valley Creek",
+ Presets: []Preset{
+ {ID: "id-vc1", Name: "Valley Creek 1"},
+ {ID: "id-pull2", Name: "Pull"}, // collision with unfiled "Pull"
+ },
+ }},
+ }
+ if p, err := pickPreset(resp, "Push"); err != nil || p.ID != "id-push" {
+ t.Errorf("exact name match on unfiled: got %+v, err=%v", p, err)
}
- if p, err := pickPreset(presets, "Push"); err != nil || p.ID != "id-push" {
- t.Errorf("exact name match: got %+v, err=%v", p, err)
+ if p, err := pickPreset(resp, "Valley Creek 1"); err != nil || p.ID != "id-vc1" {
+ t.Errorf("name match on foldered routine: got %+v, err=%v", p, err)
}
- if p, err := pickPreset(presets, "push"); err != nil || p.ID != "id-push" {
- t.Errorf("case-insensitive name match: got %+v, err=%v", p, err)
+ if p, err := pickPreset(resp, "valley creek 1"); err != nil || p.ID != "id-vc1" {
+ t.Errorf("case-insensitive folder match: got %+v, err=%v", p, err)
}
- if p, err := pickPreset(presets, "id-push"); err != nil || p.ID != "id-push" {
- t.Errorf("id match: got %+v, err=%v", p, err)
+ if p, err := pickPreset(resp, "id-vc1"); err != nil || p.ID != "id-vc1" {
+ t.Errorf("id match on foldered routine: got %+v, err=%v", p, err)
}
- if _, err := pickPreset(presets, "Pull"); err == nil {
- t.Errorf("colliding name should error to force disambiguation by id")
+ if _, err := pickPreset(resp, "Pull"); err == nil {
+ t.Errorf("collision across folder boundary should error")
}
- if _, err := pickPreset(presets, "nope"); err == nil {
+ if _, err := pickPreset(resp, "nope"); err == nil {
t.Errorf("missing name+id should error")
}
}