From d8718aa3155332b90220661c6a5e173e9173bd57 Mon Sep 17 00:00:00 2001 From: Vincent Royer Date: Fri, 26 Jun 2026 11:03:12 -0700 Subject: [PATCH] workouts list/show JSON: numeric bodyweight + sessionDurationSeconds (#33, #36) The workouts JSON marshalled two fields in shapes that are awkward for downstream consumers: - bodyweight came through as a quoted string ("175") while `workouts stats` emits it as a number, so the same field had two types depending on the subcommand (#33). - sessionDuration is a human phrase ("01 hours 06 minutes 01 seconds"), so it cannot be summed or averaged without parsing prose (#36). Add a Post.MarshalJSON that: - emits bodyweight as a JSON number (null when absent/unparseable), matching `workouts stats`; - keeps the human sessionDuration string and adds a numeric sessionDurationSeconds beside it. Decoding is unchanged (the API still sends strings; only output is normalized). Adds tests for the marshalled types and the duration parser, including null/empty and malformed-input cases. Co-Authored-By: Claude Opus 4.8 --- cmd/workouts.go | 70 +++++++++++++++++++++++++++++++ cmd/workouts_json_test.go | 86 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+) create mode 100644 cmd/workouts_json_test.go diff --git a/cmd/workouts.go b/cmd/workouts.go index c4422ac..ae79bf4 100644 --- a/cmd/workouts.go +++ b/cmd/workouts.go @@ -6,6 +6,7 @@ import ( "io" "os" "reflect" + "strconv" "strings" "time" @@ -49,6 +50,75 @@ type Post struct { ExerciseData []ExerciseData `json:"exerciseData"` } +// MarshalJSON normalizes the field types in `workouts list`/`show` JSON so they +// match the rest of the contract. The Liftoff API delivers two fields in +// awkward forms that we keep decoding as strings but emit more usefully: +// +// - bodyweight arrives as a quoted string ("175"); we emit it as a JSON +// number (or null when absent/unparseable) so it matches the numeric +// bodyweight in `workouts stats`. (#33) +// - sessionDuration is a human phrase ("01 hours 06 minutes 01 seconds"). +// We keep it for display but add a numeric sessionDurationSeconds so +// consumers can do arithmetic without parsing prose. (#36) +func (p Post) MarshalJSON() ([]byte, error) { + // alias drops Post's methods, so this Marshal call is not recursive. The + // outer bodyweight field shadows the embedded string field (same tag, + // shallower depth wins), replacing it with a number/null. + type alias Post + return json.Marshal(struct { + alias + Bodyweight *float64 `json:"bodyweight"` + SessionDurationSeconds *int `json:"sessionDurationSeconds"` + }{ + alias: alias(p), + Bodyweight: parseBodyweightValue(p.Bodyweight), + SessionDurationSeconds: parseDurationSeconds(p.SessionDuration), + }) +} + +// parseBodyweightValue converts the API's string bodyweight to a number. +// Empty or unparseable values yield nil, which marshals as null. +func parseBodyweightValue(s string) *float64 { + s = strings.TrimSpace(s) + if s == "" { + return nil + } + f, err := strconv.ParseFloat(s, 64) + if err != nil { + return nil + } + return &f +} + +// parseDurationSeconds parses Liftoff's "NN hours NN minutes NN seconds" +// session-duration phrase into a total number of seconds. It tolerates a +// missing unit group (e.g. "45 minutes 30 seconds") but returns nil on any +// shape it does not recognize rather than guessing. +func parseDurationSeconds(s string) *int { + fields := strings.Fields(s) + if len(fields) < 2 || len(fields)%2 != 0 { + return nil + } + total := 0 + for i := 0; i+1 < len(fields); i += 2 { + n, err := strconv.Atoi(fields[i]) + if err != nil { + return nil + } + switch strings.ToLower(strings.TrimSuffix(fields[i+1], "s")) { + case "hour": + total += n * 3600 + case "minute", "min": + total += n * 60 + case "second", "sec": + total += n + default: + return nil + } + } + return &total +} + var listFormatFlag string var listSinceFlag string var listUntilFlag string diff --git a/cmd/workouts_json_test.go b/cmd/workouts_json_test.go new file mode 100644 index 0000000..cd07019 --- /dev/null +++ b/cmd/workouts_json_test.go @@ -0,0 +1,86 @@ +package cmd + +import ( + "encoding/json" + "strings" + "testing" +) + +// workouts list/show JSON must emit bodyweight as a number (matching +// `workouts stats`) and add a numeric sessionDurationSeconds alongside the +// human sessionDuration string. (#33, #36) +func TestPostMarshalJSON_NumericFields(t *testing.T) { + p := Post{ + ID: "abc", + StartedAt: "2026-06-11T03:14:12.665Z", + SessionDuration: "01 hours 06 minutes 01 seconds", + Bodyweight: "175", + CaloriesBurned: 56, + } + + b, err := json.Marshal(p) + if err != nil { + t.Fatalf("marshal: %v", err) + } + raw := string(b) + + // bodyweight must be a bare number, not a quoted string. + if strings.Contains(raw, `"bodyweight":"175"`) { + t.Errorf("bodyweight should be a number, got quoted string in:\n%s", raw) + } + + var got struct { + Bodyweight *float64 `json:"bodyweight"` + SessionDuration string `json:"sessionDuration"` + SessionDurationSeconds *int `json:"sessionDurationSeconds"` + } + if err := json.Unmarshal(b, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got.Bodyweight == nil || *got.Bodyweight != 175 { + t.Errorf("bodyweight = %v, want 175", got.Bodyweight) + } + if got.SessionDuration != "01 hours 06 minutes 01 seconds" { + t.Errorf("human sessionDuration should be preserved, got %q", got.SessionDuration) + } + if got.SessionDurationSeconds == nil || *got.SessionDurationSeconds != 3961 { + t.Errorf("sessionDurationSeconds = %v, want 3961 (1h06m01s)", got.SessionDurationSeconds) + } +} + +// An absent bodyweight marshals as null, not "". +func TestPostMarshalJSON_EmptyBodyweightIsNull(t *testing.T) { + b, err := json.Marshal(Post{StartedAt: "2026-06-11T03:14:12Z"}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if !strings.Contains(string(b), `"bodyweight":null`) { + t.Errorf("empty bodyweight should marshal as null, got:\n%s", string(b)) + } +} + +func TestParseDurationSeconds(t *testing.T) { + cases := []struct { + in string + want *int + }{ + {"01 hours 06 minutes 01 seconds", intp(3961)}, + {"45 minutes 30 seconds", intp(2730)}, + {"2 hours", intp(7200)}, + {"", nil}, + {"a while", nil}, // non-numeric + {"5 fortnights", nil}, // unknown unit + {"10 minutes 5", nil}, // odd field count + } + for _, c := range cases { + got := parseDurationSeconds(c.in) + switch { + case c.want == nil && got != nil: + t.Errorf("parseDurationSeconds(%q) = %d, want nil", c.in, *got) + case c.want != nil && (got == nil || *got != *c.want): + t.Errorf("parseDurationSeconds(%q) = %v, want %d", c.in, got, *c.want) + } + } +} + +func intp(n int) *int { return &n }