From e4d81eeccd338c3d235cfda5a061d4526b0cb878 Mon Sep 17 00:00:00 2001 From: DTTerastar Date: Mon, 15 Jun 2026 01:12:19 -0400 Subject: [PATCH] fix(routines): drop placeholder zero sets and "@+0" bodyweight suffix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fitdown polish fixes the spec is silent on: 1. Exercises added to a routine but never given target sets/reps arrived as zero/zero placeholder sets and rendered as "0@0" / "0@+0" / "0:00". They now show just the exercise name — still part of the routine, but no fake numbers. 2. WR / BR / AB sets with a zero weight component (a true bodyweight rep with no added load or assistance) now emit just the rep count: "5" instead of "5@+0" or "5@-0". The exercise name and type carry the rest of the context. Also DRYs the WR/BR/AB/WD/DD/ND set-line switch — workouts.go was duplicating routines.go's `fitdownSetLine`. Now both renderers share it, which is also how the polish fix above applies to both surfaces in one place. Signature changed to (exTypes, inputOne, inputTwo) so the helper doesn't care whether the caller has a SetData or PresetSetData. Tests cover the suppression for WR + ND, the WR/BR/AB zero-weight simplification, and assert real numbers still render unchanged. Co-Authored-By: Claude Opus 4.7 Co-authored-by: Orca --- cmd/routines.go | 74 +++++++++++++++++++++++++++++++++++--------- cmd/routines_test.go | 43 +++++++++++++++++++++++++ cmd/workouts.go | 23 +------------- cmd/workouts_test.go | 31 ++++++++++++------- 4 files changed, 123 insertions(+), 48 deletions(-) diff --git a/cmd/routines.go b/cmd/routines.go index 553d5bd..5938676 100644 --- a/cmd/routines.go +++ b/cmd/routines.go @@ -252,9 +252,17 @@ func renderOnePreset(w io.Writer, p Preset, headingMarker string) error { name = fmt.Sprintf("%s (%s)", name, *ex.ExerciseNotes) } fmt.Fprintln(w, name) + // An exercise added to a routine but never given target sets/reps + // arrives as one (or more) all-zero placeholder sets. Rendering them + // as `0@0` / `0@+0` / `0:00` looks like nonsense data; suppress the + // set lines but keep the exercise name so the LLM-agent reader still + // sees that the exercise is part of the routine. + if allSetsAreZero(ex.SetsData) { + continue + } var lines []string for _, s := range ex.SetsData { - lines = append(lines, fitdownSetLine(ex.ExerciseTypes, s)) + lines = append(lines, fitdownSetLine(ex.ExerciseTypes, s.InputOne, s.InputTwo)) } // Compress consecutive identical lines into Nx... notation. for i := 0; i < len(lines); { @@ -273,30 +281,66 @@ func renderOnePreset(w io.Writer, p Preset, headingMarker string) error { 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 { +// allSetsAreZero reports whether every set in the slice has both inputs == 0. +// Used to detect placeholder sets in routines (an exercise added but never +// given a target) and an empty `ND` time entry in either renderer. +func allSetsAreZero(sets []PresetSetData) bool { + if len(sets) == 0 { + return true + } + for _, s := range sets { + a, _ := s.InputOne.Float64() + b, _ := s.InputTwo.Float64() + if a != 0 || b != 0 { + return false + } + } + return true +} + +// fitdownSetLine renders one set line in fitdown notation. Shared by the +// workouts and routines renderers — both receive the same `exerciseTypes` +// tag from Liftoff and emit identical notation. The fitdown spec only +// documents `${reps}@${poundage}` and `Nx` compression; everything else +// (WR/BR/AB/WD/DD/ND mapping, the `+`/`-` weight prefixes, the bodyweight +// simplification below) is our extension. +// +// Simplification: when a WR/BR/AB set's weight component is 0 — i.e. a +// bodyweight rep with no extra load or assistance — we drop the `@…` +// suffix and emit just the rep count. "5" is clearer than "5@+0". +func fitdownSetLine(exTypes string, inputOne, inputTwo json.Number) string { + weightZero := func() bool { + v, _ := inputOne.Float64() + return v == 0 + } switch exTypes { case "WR": - return fmt.Sprintf("%s@%s", s.InputTwo, s.InputOne) + if weightZero() { + return fmt.Sprintf("%s", inputTwo) + } + return fmt.Sprintf("%s@%s", inputTwo, inputOne) case "AB": - return fmt.Sprintf("%s@-%s", s.InputTwo, s.InputOne) + if weightZero() { + return fmt.Sprintf("%s", inputTwo) + } + return fmt.Sprintf("%s@-%s", inputTwo, inputOne) case "BR": - return fmt.Sprintf("%s@+%s", s.InputTwo, s.InputOne) + if weightZero() { + return fmt.Sprintf("%s", inputTwo) + } + return fmt.Sprintf("%s@+%s", inputTwo, inputOne) case "WD": - km, _ := s.InputTwo.Float64() - return fmt.Sprintf("%slb %.3fmi", s.InputOne, km/1.60934) + km, _ := inputTwo.Float64() + return fmt.Sprintf("%slb %.3fmi", inputOne, km/1.60934) case "DD": - secs, _ := s.InputTwo.Int64() - km, _ := s.InputOne.Float64() + secs, _ := inputTwo.Int64() + km, _ := inputOne.Float64() return fmt.Sprintf("%.2fmi %d:%02d", km/1.60934, secs/60, secs%60) case "ND": - secs, _ := s.InputTwo.Int64() + secs, _ := inputTwo.Int64() return fmt.Sprintf("%d:%02d", secs/60, secs%60) default: - return fmt.Sprintf("[%s] %s %s", exTypes, s.InputOne, s.InputTwo) + return fmt.Sprintf("[%s] %s %s", exTypes, inputOne, inputTwo) } } diff --git a/cmd/routines_test.go b/cmd/routines_test.go index f10f852..310d84f 100644 --- a/cmd/routines_test.go +++ b/cmd/routines_test.go @@ -172,6 +172,49 @@ func TestRenderOnePreset_ShowUsesH1(t *testing.T) { } } +func TestRenderOnePreset_AllZeroSetsSuppressed(t *testing.T) { + // An exercise added to a routine but never given target sets/reps + // arrives as one or more zero/zero placeholder sets. The exercise + // name should still appear (the LLM-agent reader cares that it's + // part of the routine) but the bogus "0@0" / "0:00" set lines must + // not. + p := Preset{ + Name: "Free Weights", + ExerciseData: []PresetExerciseData{ + { + ExerciseName: "Bench Press", + ExerciseTypes: "WR", + SetsData: []PresetSetData{{InputOne: json.Number("100"), InputTwo: json.Number("5")}}, + }, + { + ExerciseName: "Placeholder", + ExerciseTypes: "WR", + SetsData: []PresetSetData{{InputOne: json.Number("0"), InputTwo: json.Number("0")}}, + }, + { + ExerciseName: "Sci-fit Upper Body", + ExerciseTypes: "ND", + SetsData: []PresetSetData{{InputOne: json.Number("0"), InputTwo: json.Number("0")}}, + }, + }, + } + var buf bytes.Buffer + renderOnePreset(&buf, p, "##") + out := buf.String() + if !strings.Contains(out, "Bench Press\n5@100") { + t.Errorf("real exercise should render normally; got:\n%s", out) + } + if !strings.Contains(out, "Placeholder") { + t.Errorf("placeholder exercise name should still appear; got:\n%s", out) + } + if strings.Contains(out, "0@0") { + t.Errorf("zero/zero WR set should be suppressed; got:\n%s", out) + } + if strings.Contains(out, "0:00") { + t.Errorf("zero ND duration set should be suppressed; got:\n%s", out) + } +} + func TestPickPreset(t *testing.T) { resp := &presetsResponse{ PresetsWithoutFolder: []Preset{ diff --git a/cmd/workouts.go b/cmd/workouts.go index c813015..c4422ac 100644 --- a/cmd/workouts.go +++ b/cmd/workouts.go @@ -308,28 +308,7 @@ func renderWorkoutsFitdown(w io.Writer, posts []Post) error { var lines []string for _, s := range ex.SetsData { - var line string - switch ex.ExerciseTypes { - case "WR": - line = fmt.Sprintf("%s@%s", s.InputTwo, s.InputOne) - case "AB": - line = fmt.Sprintf("%s@-%s", s.InputTwo, s.InputOne) - case "BR": - line = fmt.Sprintf("%s@+%s", s.InputTwo, s.InputOne) - case "WD": - km, _ := s.InputTwo.Float64() - line = fmt.Sprintf("%slb %.3fmi", s.InputOne, km/1.60934) - case "DD": - secs, _ := s.InputTwo.Int64() - km, _ := s.InputOne.Float64() - line = fmt.Sprintf("%.2fmi %d:%02d", km/1.60934, secs/60, secs%60) - case "ND": - secs, _ := s.InputTwo.Int64() - line = fmt.Sprintf("%d:%02d", secs/60, secs%60) - default: - line = fmt.Sprintf("[%s] %s %s", ex.ExerciseTypes, s.InputOne, s.InputTwo) - } - lines = append(lines, line) + lines = append(lines, fitdownSetLine(ex.ExerciseTypes, s.InputOne, s.InputTwo)) } // Compress consecutive identical lines into Nx... notation diff --git a/cmd/workouts_test.go b/cmd/workouts_test.go index 55a1d3b..cd670db 100644 --- a/cmd/workouts_test.go +++ b/cmd/workouts_test.go @@ -66,23 +66,28 @@ func TestRenderWorkoutsFitdown_ExerciseNoteInParens(t *testing.T) { } } -// 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. +// Representative cases per ExerciseType so a regression in the shared +// fitdownSetLine surfaces in CI. The bodyweight cases (BR/AB with zero +// weight component) verify the @+0/@-0 simplification — the spec is silent +// on these so the rule lives in code, not docs. func TestRenderWorkoutsFitdown_SetNotation(t *testing.T) { cases := []struct { + name string exType string one, two string want string + notWant string // optional negative assertion }{ - {"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 + {"WR weighted", "WR", "100", "5", "5@100", ""}, + {"WR zero weight simplifies", "WR", "0", "5", "\n5\n", "5@0"}, + {"BR with extra weight", "BR", "12", "5", "5@+12", ""}, + {"BR zero added simplifies", "BR", "0", "10", "\n10\n", "10@+0"}, + {"AB with assistance", "AB", "20", "10", "10@-20", ""}, + {"AB zero assistance simplifies", "AB", "0", "10", "\n10\n", "10@-0"}, + {"ND duration", "ND", "0", "495", "8:15", ""}, } for _, c := range cases { - t.Run(c.exType, func(t *testing.T) { + t.Run(c.name, func(t *testing.T) { w := bareWorkout() w.ExerciseData[0].ExerciseTypes = c.exType w.ExerciseData[0].SetsData[0] = SetData{ @@ -91,8 +96,12 @@ func TestRenderWorkoutsFitdown_SetNotation(t *testing.T) { } 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()) + out := buf.String() + if !strings.Contains(out, c.want) { + t.Errorf("expected %q in output; got:\n%s", c.want, out) + } + if c.notWant != "" && strings.Contains(out, c.notWant) { + t.Errorf("did NOT want %q in output (should have been simplified); got:\n%s", c.notWant, out) } }) }