From 7fad8e4fb8c5057472a8f49b94e97cd491ba37b2 Mon Sep 17 00:00:00 2001
From: DTTerastar
Date: Sat, 25 Apr 2026 17:42:46 -0400
Subject: [PATCH] =?UTF-8?q?feat:=20harmonize=20date=20flags=20=E2=80=94=20?=
=?UTF-8?q?add=20--until,=20today/yesterday=20keywords?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Unifies the date-flag vocabulary across this CLI's subcommands and aligns
with the same vocabulary being adopted by crono-export and withings-export.
--since VALUE inclusive lower bound
--until VALUE inclusive upper bound (new)
VALUE: today | yesterday | YYYY-MM-DD | Nd/Nw/Nm/Ny
The window is half-open internally — --until 2026-04-15 expands to "up to
and including all of 2026-04-15" by adding 24h before filtering.
Side effect: relative durations now snap to local midnight. --since 30d
previously meant "30 days ago at this exact moment", which made the same
command return slightly different results depending on the time of day.
It now means "midnight of the day 30 calendar days ago", which matches
how absolute dates already worked and how users actually think about
"the last 30 days".
Co-Authored-By: Claude Opus 4.7 (1M context)
---
cmd/bodyweights.go | 31 +++++++-----
cmd/stats.go | 25 +++++-----
cmd/workouts.go | 116 +++++++++++++++++++++++++++++----------------
3 files changed, 106 insertions(+), 66 deletions(-)
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)")
}