diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f2b99c5..49355b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,7 +44,7 @@ jobs: env: # Path the compat-tagged test reads from os.Getenv. LIFTOFF_EXPORT_BIN: /tmp/liftoff-export - run: go test -tags=compat -run TestContractFormats ./... + run: go test -tags=compat -run 'TestContractFormats|TestResolvedWindow' ./... artifacts: # Cross-compile review binaries for the platforms reviewers actually use. diff --git a/cmd/bodyweights.go b/cmd/bodyweights.go index b55fc08..46be119 100644 --- a/cmd/bodyweights.go +++ b/cmd/bodyweights.go @@ -44,13 +44,13 @@ type bodyweightJSON struct { } type bodyweightStatsJSON struct { - Current float64 `json:"current"` - High float64 `json:"high"` - Low float64 `json:"low"` - Change float64 `json:"change"` - RatePerMonth *float64 `json:"ratePerMonth,omitempty"` - Plateau *plateauJSON `json:"plateau,omitempty"` - Months []monthAvgJSON `json:"months"` + Current float64 `json:"current"` + High float64 `json:"high"` + Low float64 `json:"low"` + Change float64 `json:"change"` + RatePerMonth *float64 `json:"ratePerMonth,omitempty"` + Plateau *plateauJSON `json:"plateau,omitempty"` + Months []monthAvgJSON `json:"months"` } type plateauJSON struct { @@ -72,7 +72,18 @@ var bodyweightsListCmd = &cobra.Command{ if err != nil { return err } - entries, err := loadBodyweightEntries(bodyweightsListSinceFlag, bodyweightsListUntilFlag) + since, err := parseDateValue(bodyweightsListSinceFlag) + if err != nil { + return err + } + until, err := parseUntilValue(bodyweightsListUntilFlag) + if err != nil { + return err + } + if printed, err := printResolvedWindow(cmd, since, until); printed || err != nil { + return err + } + entries, err := loadBodyweightEntries(since, until) if err != nil { return err } @@ -106,7 +117,18 @@ var bodyweightsStatsCmd = &cobra.Command{ if err != nil { return err } - entries, err := loadBodyweightEntries(bodyweightsStatsSinceFlag, bodyweightsStatsUntilFlag) + since, err := parseDateValue(bodyweightsStatsSinceFlag) + if err != nil { + return err + } + until, err := parseUntilValue(bodyweightsStatsUntilFlag) + if err != nil { + return err + } + if printed, err := printResolvedWindow(cmd, since, until); printed || err != nil { + return err + } + entries, err := loadBodyweightEntries(since, until) if err != nil { return err } @@ -137,22 +159,13 @@ func init() { bodyweightsStatsCmd.Flags().StringVar(&bodyweightsStatsFormatFlag, "format", "markdown", "Output format: markdown (default) or json") } -func loadBodyweightEntries(sinceFlag, untilFlag string) ([]bodyweightEntry, error) { +func loadBodyweightEntries(since, until time.Time) ([]bodyweightEntry, error) { c := client.New() var posts []Post if err := c.Query("post.getMyPosts", nil, &posts); err != nil { return nil, err } - since, err := parseDateValue(sinceFlag) - if err != nil { - return nil, err - } - until, err := parseUntilValue(untilFlag) - if err != nil { - return nil, err - } - entries := make([]bodyweightEntry, 0, len(posts)) for _, post := range posts { weight, err := strconv.ParseFloat(strings.TrimSpace(post.Bodyweight), 64) diff --git a/cmd/resolved_window.go b/cmd/resolved_window.go new file mode 100644 index 0000000..bd5a4f0 --- /dev/null +++ b/cmd/resolved_window.go @@ -0,0 +1,28 @@ +package cmd + +import ( + "fmt" + "github.com/spf13/cobra" + "time" +) + +func init() { + for _, cmd := range []*cobra.Command{listCmd, statsCmd, bodyweightsListCmd, bodyweightsStatsCmd} { + cmd.Flags().Bool("print-resolved-window", false, "Print resolved dates without contacting Liftoff") + } +} + +func printResolvedWindow(cmd *cobra.Command, since, until time.Time) (bool, error) { + enabled, err := cmd.Flags().GetBool("print-resolved-window") + if err != nil || !enabled { + return false, err + } + format := func(t time.Time) string { + if t.IsZero() { + return "unbounded" + } + return t.Format(time.RFC3339) + } + _, err = fmt.Fprintf(cmd.OutOrStdout(), "since=%s until_exclusive=%s\n", format(since), format(until)) + return true, err +} diff --git a/cmd/resolved_window_test.go b/cmd/resolved_window_test.go new file mode 100644 index 0000000..7a11a11 --- /dev/null +++ b/cmd/resolved_window_test.go @@ -0,0 +1,35 @@ +package cmd + +import ( + "testing" + "time" +) + +func TestFilterByWindowIncludesEntireDSTDay(t *testing.T) { + loc, err := time.LoadLocation("America/New_York") + if err != nil { + t.Fatal(err) + } + old := time.Local + time.Local = loc + t.Cleanup(func() { time.Local = old }) + for _, tc := range []struct{ day, inside, after string }{ + {"2026-03-08", "2026-03-08T23:59:59-04:00", "2026-03-09T00:00:00-04:00"}, + {"2026-11-01", "2026-11-01T23:59:59-05:00", "2026-11-02T00:00:00-05:00"}, + } { + t.Run(tc.day, func(t *testing.T) { + start, err := parseDateValue(tc.day) + if err != nil { + t.Fatal(err) + } + end, err := parseUntilValue(tc.day) + if err != nil { + t.Fatal(err) + } + got := filterByWindow([]Post{{StartedAt: tc.inside}, {StartedAt: tc.after}}, start, end) + if len(got) != 1 || got[0].StartedAt != tc.inside { + t.Fatalf("filtered posts=%+v", got) + } + }) + } +} diff --git a/cmd/stats.go b/cmd/stats.go index cfed562..b96b50e 100644 --- a/cmd/stats.go +++ b/cmd/stats.go @@ -47,11 +47,6 @@ var statsCmd = &cobra.Command{ if err != nil { return err } - c := client.New() - var posts []Post - if err := c.Query("post.getMyPosts", nil, &posts); err != nil { - return err - } since, err := parseDateValue(statsSinceFlag) if err != nil { return err @@ -60,6 +55,14 @@ var statsCmd = &cobra.Command{ if err != nil { return err } + if printed, err := printResolvedWindow(cmd, since, until); printed || err != nil { + return err + } + c := client.New() + var posts []Post + if err := c.Query("post.getMyPosts", nil, &posts); err != nil { + return err + } posts = filterByWindow(posts, since, until) if statsExerciseFlag != "" { posts = filterExercises(posts, statsExerciseFlag) diff --git a/cmd/workouts.go b/cmd/workouts.go index ae79bf4..3daeba6 100644 --- a/cmd/workouts.go +++ b/cmd/workouts.go @@ -21,10 +21,10 @@ var workoutsCmd = &cobra.Command{ // SetData mirrors the setsData array items. type SetData struct { - SetIndex int `json:"setIndex"` - SetType string `json:"setType"` - InputOne json.Number `json:"inputOne"` // weight (kg) or distance - InputTwo json.Number `json:"inputTwo"` // reps or duration (seconds) + SetIndex int `json:"setIndex"` + SetType string `json:"setType"` + InputOne json.Number `json:"inputOne"` // weight (kg) or distance + InputTwo json.Number `json:"inputTwo"` // reps or duration (seconds) } // ExerciseData mirrors the exerciseData array items. @@ -132,12 +132,6 @@ var listCmd = &cobra.Command{ if err != nil { return err } - c := client.New() - var posts []Post - // post.getMyPosts returns all the user's own workouts (Memories view) - if err := c.Query("post.getMyPosts", nil, &posts); err != nil { - return err - } since, err := parseDateValue(listSinceFlag) if err != nil { return err @@ -146,6 +140,15 @@ var listCmd = &cobra.Command{ if err != nil { return err } + if printed, err := printResolvedWindow(cmd, since, until); printed || err != nil { + return err + } + c := client.New() + var posts []Post + // post.getMyPosts returns all the user's own workouts (Memories view) + if err := c.Query("post.getMyPosts", nil, &posts); err != nil { + return err + } posts = filterByWindow(posts, since, until) if listExerciseFlag != "" { posts = filterExercises(posts, listExerciseFlag) diff --git a/compat_contract_test.go b/compat_contract_test.go index 6d00ba1..538b2e9 100644 --- a/compat_contract_test.go +++ b/compat_contract_test.go @@ -16,6 +16,7 @@ import ( "testing" "github.com/quantcli/common/compat" + "github.com/quantcli/common/compat/dates" "github.com/quantcli/common/compat/formats" ) @@ -61,3 +62,12 @@ func TestContractFormats(t *testing.T) { SkipDataPath: true, }) } + +// TestResolvedWindow verifies local dates independently of upstream credentials. +func TestResolvedWindow(t *testing.T) { + bin := os.Getenv("LIFTOFF_EXPORT_BIN") + if bin == "" { + t.Skip("LIFTOFF_EXPORT_BIN not set") + } + dates.RunResolvedWindow(t, compat.Runner{Binary: bin, Subcommands: []string{"workouts list", "workouts stats", "bodyweights list", "bodyweights stats"}}) +} diff --git a/go.mod b/go.mod index af8224f..411d63b 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/quantcli/liftoff-export-cli go 1.26.10 require ( - github.com/quantcli/common/compat v0.0.0-20260519113336-de9219e03369 + github.com/quantcli/common/compat v0.0.0-20261110001356-0b66a42dfa85 github.com/spf13/cobra v1.10.2 ) diff --git a/go.sum b/go.sum index 7b3afba..691839b 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/quantcli/common/compat v0.0.0-20260519113336-de9219e03369 h1:Ph45Q92vEk4rpcR/NGH/j0oBM9pin2Dk8Rh+Q2mhj3k= -github.com/quantcli/common/compat v0.0.0-20260519113336-de9219e03369/go.mod h1:VBC/zEphSZgCZS1rhWsR3A8EWYSbTkP/MwqWHL7266s= +github.com/quantcli/common/compat v0.0.0-20261110001356-0b66a42dfa85 h1:NxUSVjE+3clP9Ss6uvlwZ0SToE7kmpkioRJ5r8ZUmK4= +github.com/quantcli/common/compat v0.0.0-20261110001356-0b66a42dfa85/go.mod h1:VBC/zEphSZgCZS1rhWsR3A8EWYSbTkP/MwqWHL7266s= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= diff --git a/resolved_window_test.go b/resolved_window_test.go new file mode 100644 index 0000000..a8c1e0e --- /dev/null +++ b/resolved_window_test.go @@ -0,0 +1,36 @@ +package main_test + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestResolvedWindowOffline(t *testing.T) { + bin := filepath.Join(t.TempDir(), "export-cli") + build := exec.Command("go", "build", "-o", bin, ".") + if out, err := build.CombinedOutput(); err != nil { + t.Fatalf("build: %v: %s", err, out) + } + cases := []struct{ zone, day, want string }{ + {"Pacific/Honolulu", "2026-04-15", "since=2026-04-15T00:00:00-10:00 until_exclusive=2026-04-16T00:00:00-10:00\n"}, + {"Pacific/Kiritimati", "2026-04-15", "since=2026-04-15T00:00:00+14:00 until_exclusive=2026-04-16T00:00:00+14:00\n"}, + {"America/New_York", "2026-03-08", "since=2026-03-08T00:00:00-05:00 until_exclusive=2026-03-09T00:00:00-04:00\n"}, + {"America/New_York", "2026-11-01", "since=2026-11-01T00:00:00-04:00 until_exclusive=2026-11-02T00:00:00-05:00\n"}, + } + for _, sub := range []string{"workouts list", "workouts stats", "bodyweights list", "bodyweights stats"} { + for _, tc := range cases { + t.Run(sub+"/"+tc.zone+"/"+tc.day, func(t *testing.T) { + args := append(strings.Fields(sub), "--since", tc.day, "--until", tc.day, "--print-resolved-window") + cmd := exec.Command(bin, args...) + cmd.Env = []string{"TZ=" + tc.zone, "HOME=" + t.TempDir(), "HTTPS_PROXY=http://127.0.0.1:1", "PATH=" + os.Getenv("PATH")} + out, err := cmd.CombinedOutput() + if err != nil || string(out) != tc.want { + t.Fatalf("output=%q error=%v, want %q", out, err, tc.want) + } + }) + } + } +}