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") } }