Skip to content

Commit 880a673

Browse files
vinistoisrvinistoisclaude
authored
workouts list/show JSON: numeric bodyweight + sessionDurationSeconds (#33, #36) (#54)
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: Vincent Royer <[email protected]> Co-authored-by: Claude Opus 4.8 <[email protected]>
1 parent d4a2282 commit 880a673

2 files changed

Lines changed: 156 additions & 0 deletions

File tree

cmd/workouts.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"io"
77
"os"
88
"reflect"
9+
"strconv"
910
"strings"
1011
"time"
1112

@@ -49,6 +50,75 @@ type Post struct {
4950
ExerciseData []ExerciseData `json:"exerciseData"`
5051
}
5152

53+
// MarshalJSON normalizes the field types in `workouts list`/`show` JSON so they
54+
// match the rest of the contract. The Liftoff API delivers two fields in
55+
// awkward forms that we keep decoding as strings but emit more usefully:
56+
//
57+
// - bodyweight arrives as a quoted string ("175"); we emit it as a JSON
58+
// number (or null when absent/unparseable) so it matches the numeric
59+
// bodyweight in `workouts stats`. (#33)
60+
// - sessionDuration is a human phrase ("01 hours 06 minutes 01 seconds").
61+
// We keep it for display but add a numeric sessionDurationSeconds so
62+
// consumers can do arithmetic without parsing prose. (#36)
63+
func (p Post) MarshalJSON() ([]byte, error) {
64+
// alias drops Post's methods, so this Marshal call is not recursive. The
65+
// outer bodyweight field shadows the embedded string field (same tag,
66+
// shallower depth wins), replacing it with a number/null.
67+
type alias Post
68+
return json.Marshal(struct {
69+
alias
70+
Bodyweight *float64 `json:"bodyweight"`
71+
SessionDurationSeconds *int `json:"sessionDurationSeconds"`
72+
}{
73+
alias: alias(p),
74+
Bodyweight: parseBodyweightValue(p.Bodyweight),
75+
SessionDurationSeconds: parseDurationSeconds(p.SessionDuration),
76+
})
77+
}
78+
79+
// parseBodyweightValue converts the API's string bodyweight to a number.
80+
// Empty or unparseable values yield nil, which marshals as null.
81+
func parseBodyweightValue(s string) *float64 {
82+
s = strings.TrimSpace(s)
83+
if s == "" {
84+
return nil
85+
}
86+
f, err := strconv.ParseFloat(s, 64)
87+
if err != nil {
88+
return nil
89+
}
90+
return &f
91+
}
92+
93+
// parseDurationSeconds parses Liftoff's "NN hours NN minutes NN seconds"
94+
// session-duration phrase into a total number of seconds. It tolerates a
95+
// missing unit group (e.g. "45 minutes 30 seconds") but returns nil on any
96+
// shape it does not recognize rather than guessing.
97+
func parseDurationSeconds(s string) *int {
98+
fields := strings.Fields(s)
99+
if len(fields) < 2 || len(fields)%2 != 0 {
100+
return nil
101+
}
102+
total := 0
103+
for i := 0; i+1 < len(fields); i += 2 {
104+
n, err := strconv.Atoi(fields[i])
105+
if err != nil {
106+
return nil
107+
}
108+
switch strings.ToLower(strings.TrimSuffix(fields[i+1], "s")) {
109+
case "hour":
110+
total += n * 3600
111+
case "minute", "min":
112+
total += n * 60
113+
case "second", "sec":
114+
total += n
115+
default:
116+
return nil
117+
}
118+
}
119+
return &total
120+
}
121+
52122
var listFormatFlag string
53123
var listSinceFlag string
54124
var listUntilFlag string

cmd/workouts_json_test.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package cmd
2+
3+
import (
4+
"encoding/json"
5+
"strings"
6+
"testing"
7+
)
8+
9+
// workouts list/show JSON must emit bodyweight as a number (matching
10+
// `workouts stats`) and add a numeric sessionDurationSeconds alongside the
11+
// human sessionDuration string. (#33, #36)
12+
func TestPostMarshalJSON_NumericFields(t *testing.T) {
13+
p := Post{
14+
ID: "abc",
15+
StartedAt: "2026-06-11T03:14:12.665Z",
16+
SessionDuration: "01 hours 06 minutes 01 seconds",
17+
Bodyweight: "175",
18+
CaloriesBurned: 56,
19+
}
20+
21+
b, err := json.Marshal(p)
22+
if err != nil {
23+
t.Fatalf("marshal: %v", err)
24+
}
25+
raw := string(b)
26+
27+
// bodyweight must be a bare number, not a quoted string.
28+
if strings.Contains(raw, `"bodyweight":"175"`) {
29+
t.Errorf("bodyweight should be a number, got quoted string in:\n%s", raw)
30+
}
31+
32+
var got struct {
33+
Bodyweight *float64 `json:"bodyweight"`
34+
SessionDuration string `json:"sessionDuration"`
35+
SessionDurationSeconds *int `json:"sessionDurationSeconds"`
36+
}
37+
if err := json.Unmarshal(b, &got); err != nil {
38+
t.Fatalf("unmarshal: %v", err)
39+
}
40+
if got.Bodyweight == nil || *got.Bodyweight != 175 {
41+
t.Errorf("bodyweight = %v, want 175", got.Bodyweight)
42+
}
43+
if got.SessionDuration != "01 hours 06 minutes 01 seconds" {
44+
t.Errorf("human sessionDuration should be preserved, got %q", got.SessionDuration)
45+
}
46+
if got.SessionDurationSeconds == nil || *got.SessionDurationSeconds != 3961 {
47+
t.Errorf("sessionDurationSeconds = %v, want 3961 (1h06m01s)", got.SessionDurationSeconds)
48+
}
49+
}
50+
51+
// An absent bodyweight marshals as null, not "".
52+
func TestPostMarshalJSON_EmptyBodyweightIsNull(t *testing.T) {
53+
b, err := json.Marshal(Post{StartedAt: "2026-06-11T03:14:12Z"})
54+
if err != nil {
55+
t.Fatalf("marshal: %v", err)
56+
}
57+
if !strings.Contains(string(b), `"bodyweight":null`) {
58+
t.Errorf("empty bodyweight should marshal as null, got:\n%s", string(b))
59+
}
60+
}
61+
62+
func TestParseDurationSeconds(t *testing.T) {
63+
cases := []struct {
64+
in string
65+
want *int
66+
}{
67+
{"01 hours 06 minutes 01 seconds", intp(3961)},
68+
{"45 minutes 30 seconds", intp(2730)},
69+
{"2 hours", intp(7200)},
70+
{"", nil},
71+
{"a while", nil}, // non-numeric
72+
{"5 fortnights", nil}, // unknown unit
73+
{"10 minutes 5", nil}, // odd field count
74+
}
75+
for _, c := range cases {
76+
got := parseDurationSeconds(c.in)
77+
switch {
78+
case c.want == nil && got != nil:
79+
t.Errorf("parseDurationSeconds(%q) = %d, want nil", c.in, *got)
80+
case c.want != nil && (got == nil || *got != *c.want):
81+
t.Errorf("parseDurationSeconds(%q) = %v, want %d", c.in, got, *c.want)
82+
}
83+
}
84+
}
85+
86+
func intp(n int) *int { return &n }

0 commit comments

Comments
 (0)