diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8779909..083d5dc 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 + path: dist/ + retention-days: 14 + if-no-files-found: error 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..553d5bd --- /dev/null +++ b/cmd/routines.go @@ -0,0 +1,310 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "io" + "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"` +} + +// 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. +// 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 []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", + 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 + } + resp, err := fetchPresets() + if err != nil { + return err + } + if format == "json" { + return printJSON(resp) + } + return renderRoutinesFitdown(os.Stdout, resp) + }, +} + +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 + } + resp, err := fetchPresets() + if err != nil { + return err + } + match, err := pickPreset(resp, args[0]) + if err != nil { + return err + } + if format == "json" { + return printJSON([]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() (*presetsResponse, error) { + c := client.New() + var resp presetsResponse + if err := c.Query("fitnessService.fetchUserPresetsWithFolders", nil, &resp); err != nil { + return nil, err + } + // 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 out +} + +// 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 { + 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) +} + +// 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) + } + 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) + 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++ + } + 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 +} + +// 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") +} diff --git a/cmd/routines_test.go b/cmd/routines_test.go new file mode 100644 index 0000000..f10f852 --- /dev/null +++ b/cmd/routines_test.go @@ -0,0 +1,207 @@ +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 unfiledOnly(presets ...Preset) *presetsResponse { + return &presetsResponse{ + Folders: []Folder{}, + PresetsWithoutFolder: presets, + } +} + +func TestRenderRoutinesFitdown_UnfiledSectionHeading(t *testing.T) { + var buf bytes.Buffer + if err := renderRoutinesFitdown(&buf, unfiledOnly(bareRoutine("Push"), bareRoutine("Pull"))); err != nil { + t.Fatal(err) + } + out := buf.String() + 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 second routine; got:\n%s", out) + } + if !strings.Contains(out, "\n---\n") { + 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) + } +} + +func TestRenderRoutinesFitdown_SingleRoutineNoSeparator(t *testing.T) { + var buf bytes.Buffer + renderRoutinesFitdown(&buf, unfiledOnly(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, 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", + 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, 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) + } + // 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, 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) { + 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(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(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(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(resp, "Pull"); err == nil { + t.Errorf("collision across folder boundary should error") + } + if _, err := pickPreset(resp, "nope"); err == nil { + t.Errorf("missing name+id should error") + } +} diff --git a/cmd/workouts.go b/cmd/workouts.go index e15d260..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,21 +281,30 @@ 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) 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.Fprintln(w, header) for _, ex := range post.ExerciseData { - fmt.Println() - fmt.Println(ex.ExerciseName) + fmt.Fprintln(w) + name := ex.ExerciseName + if ex.ExerciseNotes != "" { + name = fmt.Sprintf("%s (%s)", name, ex.ExerciseNotes) + } + fmt.Fprintln(w, name) var lines []string for _, s := range ex.SetsData { @@ -329,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()) + } + }) + } +}