diff --git a/cmd/bodyweights.go b/cmd/bodyweights.go index 99f5e1c..e57825e 100644 --- a/cmd/bodyweights.go +++ b/cmd/bodyweights.go @@ -24,7 +24,9 @@ type monthAvg struct { var ( bodyweightsListSinceFlag string + bodyweightsListUntilFlag string bodyweightsStatsSinceFlag string + bodyweightsStatsUntilFlag string ) var bodyweightsCmd = &cobra.Command{ @@ -36,7 +38,7 @@ var bodyweightsListCmd = &cobra.Command{ Use: "list", Short: "List recorded bodyweights", RunE: func(cmd *cobra.Command, args []string) error { - entries, err := loadBodyweightEntries(bodyweightsListSinceFlag) + entries, err := loadBodyweightEntries(bodyweightsListSinceFlag, bodyweightsListUntilFlag) if err != nil { return err } @@ -56,7 +58,7 @@ var bodyweightsStatsCmd = &cobra.Command{ Use: "stats", Short: "Show bodyweight statistics", RunE: func(cmd *cobra.Command, args []string) error { - entries, err := loadBodyweightEntries(bodyweightsStatsSinceFlag) + entries, err := loadBodyweightEntries(bodyweightsStatsSinceFlag, bodyweightsStatsUntilFlag) if err != nil { return err } @@ -74,24 +76,26 @@ func init() { bodyweightsCmd.AddCommand(bodyweightsListCmd) bodyweightsCmd.AddCommand(bodyweightsStatsCmd) - bodyweightsListCmd.Flags().StringVar(&bodyweightsListSinceFlag, "since", "", "Filter entries on or after date (e.g. 2025-01-01, 30d, 4w, 6m, 1y)") - bodyweightsStatsCmd.Flags().StringVar(&bodyweightsStatsSinceFlag, "since", "", "Filter entries on or after date (e.g. 2025-01-01, 30d, 4w, 6m, 1y)") + bodyweightsListCmd.Flags().StringVar(&bodyweightsListSinceFlag, "since", "", "Filter entries on or after date (today, yesterday, YYYY-MM-DD, or Nd/Nw/Nm/Ny)") + bodyweightsListCmd.Flags().StringVar(&bodyweightsListUntilFlag, "until", "", "Filter entries through date, inclusive (today, yesterday, YYYY-MM-DD, or Nd/Nw/Nm/Ny)") + bodyweightsStatsCmd.Flags().StringVar(&bodyweightsStatsSinceFlag, "since", "", "Filter entries on or after date (today, yesterday, YYYY-MM-DD, or Nd/Nw/Nm/Ny)") + bodyweightsStatsCmd.Flags().StringVar(&bodyweightsStatsUntilFlag, "until", "", "Filter entries through date, inclusive (today, yesterday, YYYY-MM-DD, or Nd/Nw/Nm/Ny)") } -func loadBodyweightEntries(sinceFlag string) ([]bodyweightEntry, error) { +func loadBodyweightEntries(sinceFlag, untilFlag string) ([]bodyweightEntry, error) { c := client.New() var posts []Post if err := c.Query("post.getMyPosts", nil, &posts); err != nil { return nil, err } - var since time.Time - if sinceFlag != "" { - parsed, err := parseSince(sinceFlag) - if err != nil { - return nil, err - } - since = parsed + 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)) @@ -109,6 +113,9 @@ func loadBodyweightEntries(sinceFlag string) ([]bodyweightEntry, error) { if !since.IsZero() && date.Before(since) { continue } + if !until.IsZero() && !date.Before(until) { + continue + } entries = append(entries, bodyweightEntry{ date: date, diff --git a/cmd/stats.go b/cmd/stats.go index 0895f71..f691e83 100644 --- a/cmd/stats.go +++ b/cmd/stats.go @@ -13,6 +13,7 @@ import ( var ( statsSinceFlag string + statsUntilFlag string statsExerciseFlag string statsJSONFlag bool statsDetailFlag bool @@ -47,20 +48,15 @@ var statsCmd = &cobra.Command{ if err := c.Query("post.getMyPosts", nil, &posts); err != nil { return err } - if statsSinceFlag != "" { - since, err := parseSince(statsSinceFlag) - if err != nil { - return err - } - var filtered []Post - for _, p := range posts { - t, err := time.Parse(time.RFC3339Nano, p.StartedAt) - if err != nil || !t.Before(since) { - filtered = append(filtered, p) - } - } - posts = filtered + since, err := parseDateValue(statsSinceFlag) + if err != nil { + return err + } + until, err := parseUntilValue(statsUntilFlag) + if err != nil { + return err } + posts = filterByWindow(posts, since, until) if statsExerciseFlag != "" { posts = filterExercises(posts, statsExerciseFlag) } @@ -89,7 +85,8 @@ var statsCmd = &cobra.Command{ func init() { workoutsCmd.AddCommand(statsCmd) - statsCmd.Flags().StringVar(&statsSinceFlag, "since", "", "Filter workouts on or after date (e.g. 2025-01-01, 30d, 4w, 6m, 1y)") + statsCmd.Flags().StringVar(&statsSinceFlag, "since", "", "Filter workouts on or after date (today, yesterday, YYYY-MM-DD, or Nd/Nw/Nm/Ny)") + statsCmd.Flags().StringVar(&statsUntilFlag, "until", "", "Filter workouts through date, inclusive (today, yesterday, YYYY-MM-DD, or Nd/Nw/Nm/Ny)") statsCmd.Flags().StringVar(&statsExerciseFlag, "exercise", "", "Filter to exercises matching this name (word-prefix match)") statsCmd.Flags().BoolVar(&statsJSONFlag, "json", false, "Output as JSON") statsCmd.Flags().BoolVar(&statsDetailFlag, "detail", false, "Show per-session breakdown") diff --git a/cmd/workouts.go b/cmd/workouts.go index 45cd804..846c779 100644 --- a/cmd/workouts.go +++ b/cmd/workouts.go @@ -49,6 +49,7 @@ type Post struct { var listJSONFlag bool var listSinceFlag string +var listUntilFlag string var listExerciseFlag string var listCmd = &cobra.Command{ @@ -61,20 +62,15 @@ var listCmd = &cobra.Command{ if err := c.Query("post.getMyPosts", nil, &posts); err != nil { return err } - if listSinceFlag != "" { - since, err := parseSince(listSinceFlag) - if err != nil { - return err - } - var filtered []Post - for _, p := range posts { - t, err := time.Parse(time.RFC3339Nano, p.StartedAt) - if err != nil || !t.Before(since) { - filtered = append(filtered, p) - } - } - posts = filtered + since, err := parseDateValue(listSinceFlag) + if err != nil { + return err } + until, err := parseUntilValue(listUntilFlag) + if err != nil { + return err + } + posts = filterByWindow(posts, since, until) if listExerciseFlag != "" { posts = filterExercises(posts, listExerciseFlag) } @@ -85,32 +81,82 @@ var listCmd = &cobra.Command{ }, } -func parseSince(s string) (time.Time, error) { - // Try absolute date first +// parseDateValue parses --since / --until / show argument values. +// Accepted forms: "today", "yesterday", absolute YYYY-MM-DD, or relative +// Nd/Nw/Nm/Ny. Returns local midnight for the target day; empty string +// yields the zero time. +func parseDateValue(s string) (time.Time, error) { + if s == "" { + return time.Time{}, nil + } + now := time.Now() + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local) + + switch strings.ToLower(s) { + case "today": + return today, nil + case "yesterday": + return today.AddDate(0, 0, -1), nil + } if t, err := time.ParseInLocation("2006-01-02", s, time.Local); err == nil { return t, nil } - // Try relative: e.g. 30d, 4w, 6m, 1y if len(s) < 2 { - return time.Time{}, fmt.Errorf("invalid --since value: %q", s) + return time.Time{}, fmt.Errorf("invalid date %q (use YYYY-MM-DD, today, yesterday, or Nd/Nw/Nm/Ny)", s) } n := 0 if _, err := fmt.Sscanf(s[:len(s)-1], "%d", &n); err != nil { - return time.Time{}, fmt.Errorf("invalid --since value: %q", s) + return time.Time{}, fmt.Errorf("invalid date %q (use YYYY-MM-DD, today, yesterday, or Nd/Nw/Nm/Ny)", s) } - now := time.Now() switch s[len(s)-1] { case 'd': - return now.AddDate(0, 0, -n), nil + return today.AddDate(0, 0, -n), nil case 'w': - return now.AddDate(0, 0, -n*7), nil + return today.AddDate(0, 0, -n*7), nil case 'm': - return now.AddDate(0, -n, 0), nil + return today.AddDate(0, -n, 0), nil case 'y': - return now.AddDate(-n, 0, 0), nil + return today.AddDate(-n, 0, 0), nil default: - return time.Time{}, fmt.Errorf("invalid --since unit %q: use d, w, m, or y", string(s[len(s)-1])) + return time.Time{}, fmt.Errorf("invalid date unit %q: use d, w, m, or y", string(s[len(s)-1])) + } +} + +// parseUntilValue resolves --until to the exclusive upper bound of a half-open +// window. The user-supplied date names a calendar day they expect to be +// included, so we add 24h to the parsed start-of-day. Empty string yields the +// zero time, which callers treat as "no upper bound". +func parseUntilValue(s string) (time.Time, error) { + t, err := parseDateValue(s) + if err != nil || t.IsZero() { + return t, err + } + return t.AddDate(0, 0, 1), nil +} + +// filterByWindow keeps posts with StartedAt in [since, until). Either bound +// being zero disables that side. RFC3339Nano parse failures are kept (the +// user can decide what to do with malformed timestamps downstream). +func filterByWindow(posts []Post, since, until time.Time) []Post { + if since.IsZero() && until.IsZero() { + return posts } + out := posts[:0] + for _, p := range posts { + t, err := time.Parse(time.RFC3339Nano, p.StartedAt) + if err != nil { + out = append(out, p) + continue + } + if !since.IsZero() && t.Before(since) { + continue + } + if !until.IsZero() && !t.Before(until) { + continue + } + out = append(out, p) + } + return out } var showJSONFlag bool @@ -120,10 +166,13 @@ var showCmd = &cobra.Command{ Short: "Show workout(s) for a given date (e.g. 2025-03-08, today, yesterday)", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - target, err := parseDate(args[0]) + target, err := parseDateValue(args[0]) if err != nil { return err } + if target.IsZero() { + return fmt.Errorf("date argument is required") + } c := client.New() var posts []Post @@ -154,26 +203,13 @@ var showCmd = &cobra.Command{ }, } -func parseDate(s string) (time.Time, error) { - now := time.Now() - switch strings.ToLower(s) { - case "today": - return now, nil - case "yesterday": - return now.AddDate(0, 0, -1), nil - } - if t, err := time.ParseInLocation("2006-01-02", s, time.Local); err == nil { - return t, nil - } - return time.Time{}, fmt.Errorf("invalid date: %q (use YYYY-MM-DD, today, or yesterday)", s) -} - func init() { workoutsCmd.AddCommand(listCmd) workoutsCmd.AddCommand(showCmd) showCmd.Flags().BoolVar(&showJSONFlag, "json", false, "Output as JSON") listCmd.Flags().BoolVar(&listJSONFlag, "json", false, "Output as JSON instead of fitdown") - listCmd.Flags().StringVar(&listSinceFlag, "since", "", "Filter workouts on or after date (e.g. 2025-01-01, 30d, 4w, 6m, 1y)") + listCmd.Flags().StringVar(&listSinceFlag, "since", "", "Filter workouts on or after date (today, yesterday, YYYY-MM-DD, or Nd/Nw/Nm/Ny)") + listCmd.Flags().StringVar(&listUntilFlag, "until", "", "Filter workouts through date, inclusive (today, yesterday, YYYY-MM-DD, or Nd/Nw/Nm/Ny)") listCmd.Flags().StringVar(&listExerciseFlag, "exercise", "", "Filter to exercises matching this name (word-prefix match)") }