Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions cmd/workouts.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"io"
"os"
"reflect"
"strconv"
"strings"
"time"

Expand Down Expand Up @@ -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
Expand Down
86 changes: 86 additions & 0 deletions cmd/workouts_json_test.go
Original file line number Diff line number Diff line change
@@ -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 }
Loading