From ca9dcc4daad96f6031e4821476e6a949cabeee9f Mon Sep 17 00:00:00 2001 From: DTTerastar Date: Sat, 25 Apr 2026 17:49:55 -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 Aligns withings-export with the shared quantcli date-flag contract (https://github.com/quantcli/common/blob/main/CONTRACT.md#3-date-flags): --since VALUE inclusive lower bound --until VALUE inclusive upper bound (new) VALUE: today | yesterday | YYYY-MM-DD | Nd/Nw/Nm/Ny Wired through every subcommand that selects a window of data: activity, sleep, workouts, measurements, intraday. ymd-format API params get the inclusive end day; epoch params get the exclusive moment. The sleep --derive iteration loop now respects --until instead of always running to time.Now(). 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. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/activity.go | 12 +++++-- cmd/intraday.go | 15 +++++--- cmd/measurements.go | 11 ++++-- cmd/shared.go | 84 +++++++++++++++++++++++++++++++++++++-------- cmd/sleep.go | 14 +++++--- cmd/workouts.go | 11 ++++-- 6 files changed, 118 insertions(+), 29 deletions(-) diff --git a/cmd/activity.go b/cmd/activity.go index 69ec599..b2be778 100644 --- a/cmd/activity.go +++ b/cmd/activity.go @@ -7,7 +7,6 @@ import ( "os" "sort" "strconv" - "time" "github.com/quantcli/withings-export-cli/internal/client" "github.com/spf13/cobra" @@ -43,6 +42,7 @@ type activityResponse struct { var ( activityFormatFlag string activitySinceFlag string + activityUntilFlag string ) var activityCmd = &cobra.Command{ @@ -53,6 +53,10 @@ var activityCmd = &cobra.Command{ if err != nil { return err } + until, err := untilDayOrToday(activityUntilFlag) + if err != nil { + return err + } dataFields := "steps,distance,elevation,soft,moderate,intense,active," + "calories,totalcalories,hr_average,hr_min,hr_max,hr_zone_0,hr_zone_1,hr_zone_2,hr_zone_3" @@ -60,7 +64,7 @@ var activityCmd = &cobra.Command{ params := url.Values{} params.Set("action", "getactivity") params.Set("startdateymd", since.Format("2006-01-02")) - params.Set("enddateymd", time.Now().Format("2006-01-02")) + params.Set("enddateymd", until.Format("2006-01-02")) params.Set("data_fields", dataFields) c := client.New() @@ -163,7 +167,9 @@ func writeActivityMarkdown(days []activityDay) error { func init() { activityCmd.Flags().StringVar(&activitySinceFlag, "since", "", - "Filter on or after date (e.g. 2026-01-01, 30d, 4w, 6m, 1y; default 30d)") + "Filter on or after date (today, yesterday, YYYY-MM-DD, or Nd/Nw/Nm/Ny; default 30d)") + activityCmd.Flags().StringVar(&activityUntilFlag, "until", "", + "Filter through date, inclusive (today, yesterday, YYYY-MM-DD, or Nd/Nw/Nm/Ny; default today)") activityCmd.Flags().StringVar(&activityFormatFlag, "format", "markdown", "Output format: markdown (default, fitdown-style), json, or csv") } diff --git a/cmd/intraday.go b/cmd/intraday.go index 02d1d00..3becff9 100644 --- a/cmd/intraday.go +++ b/cmd/intraday.go @@ -54,6 +54,7 @@ type intradayResponse struct { var ( intradayFormatFlag string intradaySinceFlag string + intradayUntilFlag string ) var intradayCmd = &cobra.Command{ @@ -72,16 +73,20 @@ Default window is the last 24h — intraday is dense; wider ranges are slow.`, if err != nil { return err } + until, err := untilOrNow(intradayUntilFlag) + if err != nil { + return err + } dataFields := "steps,elevation,calories,distance,duration,heart_rate," + "hrv_quality,rmssd,sdnn1,spo2_auto" c := client.New() var all []intradaySample - for chunkStart := since; chunkStart.Before(time.Now()); chunkStart = chunkStart.Add(intradayWindow) { + for chunkStart := since; chunkStart.Before(until); chunkStart = chunkStart.Add(intradayWindow) { chunkEnd := chunkStart.Add(intradayWindow) - if chunkEnd.After(time.Now()) { - chunkEnd = time.Now() + if chunkEnd.After(until) { + chunkEnd = until } params := url.Values{} @@ -214,7 +219,9 @@ func writeIntradayMarkdown(samples []intradaySample) error { func init() { intradayCmd.Flags().StringVar(&intradaySinceFlag, "since", "", - "Filter on or after date (e.g. 2026-04-15, 1d, 4w, 6m; default 1d)") + "Filter on or after date (today, yesterday, YYYY-MM-DD, or Nd/Nw/Nm/Ny; default 1d)") + intradayCmd.Flags().StringVar(&intradayUntilFlag, "until", "", + "Filter through date, inclusive (today, yesterday, YYYY-MM-DD, or Nd/Nw/Nm/Ny; default now)") intradayCmd.Flags().StringVar(&intradayFormatFlag, "format", "markdown", "Output format: markdown (default, fitdown-style), json, or csv") } diff --git a/cmd/measurements.go b/cmd/measurements.go index f40b34f..d75dda6 100644 --- a/cmd/measurements.go +++ b/cmd/measurements.go @@ -72,6 +72,7 @@ type measurementRow struct { var ( measurementsFormatFlag string measurementsSinceFlag string + measurementsUntilFlag string measurementsTypesFlag string ) @@ -83,12 +84,16 @@ var measurementsCmd = &cobra.Command{ if err != nil { return err } + until, err := untilOrNow(measurementsUntilFlag) + if err != nil { + return err + } params := url.Values{} params.Set("action", "getmeas") params.Set("category", "1") params.Set("startdate", strconv.FormatInt(since.Unix(), 10)) - params.Set("enddate", strconv.FormatInt(time.Now().Unix(), 10)) + params.Set("enddate", strconv.FormatInt(until.Unix(), 10)) if measurementsTypesFlag != "" { params.Set("meastypes", measurementsTypesFlag) } @@ -210,7 +215,9 @@ func writeMeasurementsMarkdown(rows []measurementRow) error { func init() { measurementsCmd.Flags().StringVar(&measurementsSinceFlag, "since", "", - "Filter on or after date (e.g. 2026-01-01, 30d, 4w, 6m, 1y; default 30d)") + "Filter on or after date (today, yesterday, YYYY-MM-DD, or Nd/Nw/Nm/Ny; default 30d)") + measurementsCmd.Flags().StringVar(&measurementsUntilFlag, "until", "", + "Filter through date, inclusive (today, yesterday, YYYY-MM-DD, or Nd/Nw/Nm/Ny; default now)") measurementsCmd.Flags().StringVar(&measurementsFormatFlag, "format", "markdown", "Output format: markdown (default, fitdown-style), json, or csv") measurementsCmd.Flags().StringVar(&measurementsTypesFlag, "types", "", diff --git a/cmd/shared.go b/cmd/shared.go index 6aedab3..69f821a 100644 --- a/cmd/shared.go +++ b/cmd/shared.go @@ -5,48 +5,104 @@ import ( "fmt" "os" "strconv" + "strings" "time" ) -// parseSince accepts absolute dates (YYYY-MM-DD) or relative values like 30d, 4w, 6m, 1y. -// An empty string yields the zero time. -func parseSince(s string) (time.Time, error) { +// parseDateValue parses --since / --until argument values per the quantcli +// shared contract: "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. See https://github.com/quantcli/common/blob/main/CONTRACT.md#3-date-flags. +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 } 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 (i.e. now)". +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 } -// sinceOrDefault returns the --since value, defaulting to daysBack days ago when empty. +// sinceOrDefault returns the --since value, defaulting to local midnight +// daysBack days ago when empty. func sinceOrDefault(s string, daysBack int) (time.Time, error) { - t, err := parseSince(s) + t, err := parseDateValue(s) + if err != nil { + return time.Time{}, err + } + if t.IsZero() { + now := time.Now() + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local) + return today.AddDate(0, 0, -daysBack), nil + } + return t, nil +} + +// untilOrNow returns the --until value as the exclusive upper bound of +// the window, defaulting to the current instant when empty. Use for epoch +// API params (Unix seconds) and client-side filtering. +func untilOrNow(s string) (time.Time, error) { + t, err := parseUntilValue(s) + if err != nil { + return time.Time{}, err + } + if t.IsZero() { + return time.Now(), nil + } + return t, nil +} + +// untilDayOrToday returns the --until value as the inclusive end calendar +// day at local midnight, defaulting to today's midnight when empty. Use +// for ymd-format API params where the API treats the date string as the +// last day to include. +func untilDayOrToday(s string) (time.Time, error) { + t, err := parseDateValue(s) if err != nil { return time.Time{}, err } if t.IsZero() { - return time.Now().AddDate(0, 0, -daysBack), nil + now := time.Now() + return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local), nil } return t, nil } diff --git a/cmd/sleep.go b/cmd/sleep.go index d5ae7d8..b1a20f2 100644 --- a/cmd/sleep.go +++ b/cmd/sleep.go @@ -36,6 +36,7 @@ type sleepResponse struct { var ( sleepFormatFlag string sleepSinceFlag string + sleepUntilFlag string sleepDeriveFlag bool ) @@ -47,6 +48,10 @@ var sleepCmd = &cobra.Command{ if err != nil { return err } + until, err := untilDayOrToday(sleepUntilFlag) + if err != nil { + return err + } // All sleep summary data fields we care about. Must be explicitly requested. dataFields := "wakeupduration,lightsleepduration,deepsleepduration,remsleepduration," + @@ -57,7 +62,7 @@ var sleepCmd = &cobra.Command{ params := url.Values{} params.Set("action", "getsummary") params.Set("startdateymd", since.Format("2006-01-02")) - params.Set("enddateymd", time.Now().Format("2006-01-02")) + params.Set("enddateymd", until.Format("2006-01-02")) params.Set("data_fields", dataFields) c := client.New() @@ -81,9 +86,8 @@ var sleepCmd = &cobra.Command{ } if sleepDeriveFlag { - today := time.Now() first := true - for d := since; !d.After(today); d = d.AddDate(0, 0, 1) { + for d := since; !d.After(until); d = d.AddDate(0, 0, 1) { dateStr := d.Format("2006-01-02") if haveDate[dateStr] { continue @@ -414,7 +418,9 @@ func writeSleepMarkdown(series []sleepSeries) error { func init() { sleepCmd.Flags().StringVar(&sleepSinceFlag, "since", "", - "Filter on or after date (e.g. 2026-01-01, 30d, 4w, 6m, 1y; default 30d)") + "Filter on or after date (today, yesterday, YYYY-MM-DD, or Nd/Nw/Nm/Ny; default 30d)") + sleepCmd.Flags().StringVar(&sleepUntilFlag, "until", "", + "Filter through date, inclusive (today, yesterday, YYYY-MM-DD, or Nd/Nw/Nm/Ny; default today)") sleepCmd.Flags().StringVar(&sleepFormatFlag, "format", "markdown", "Output format: markdown (default, fitdown-style), json, or csv") sleepCmd.Flags().BoolVar(&sleepDeriveFlag, "derive", false, diff --git a/cmd/workouts.go b/cmd/workouts.go index 2eaedfb..0c65178 100644 --- a/cmd/workouts.go +++ b/cmd/workouts.go @@ -88,6 +88,7 @@ type workoutsResponse struct { var ( workoutsFormatFlag string workoutsSinceFlag string + workoutsUntilFlag string ) var workoutsCmd = &cobra.Command{ @@ -98,6 +99,10 @@ var workoutsCmd = &cobra.Command{ if err != nil { return err } + until, err := untilDayOrToday(workoutsUntilFlag) + if err != nil { + return err + } dataFields := "calories,effduration,intensity,manual_distance,manual_calories," + "hr_average,hr_min,hr_max,hr_zone_0,hr_zone_1,hr_zone_2,hr_zone_3," + @@ -107,7 +112,7 @@ var workoutsCmd = &cobra.Command{ params := url.Values{} params.Set("action", "getworkouts") params.Set("startdateymd", since.Format("2006-01-02")) - params.Set("enddateymd", time.Now().Format("2006-01-02")) + params.Set("enddateymd", until.Format("2006-01-02")) params.Set("data_fields", dataFields) c := client.New() @@ -266,7 +271,9 @@ func writeWorkoutsMarkdown(series []workoutSeries) error { func init() { workoutsCmd.Flags().StringVar(&workoutsSinceFlag, "since", "", - "Filter on or after date (e.g. 2026-01-01, 30d, 4w, 6m, 1y; default 90d)") + "Filter on or after date (today, yesterday, YYYY-MM-DD, or Nd/Nw/Nm/Ny; default 90d)") + workoutsCmd.Flags().StringVar(&workoutsUntilFlag, "until", "", + "Filter through date, inclusive (today, yesterday, YYYY-MM-DD, or Nd/Nw/Nm/Ny; default today)") workoutsCmd.Flags().StringVar(&workoutsFormatFlag, "format", "markdown", "Output format: markdown (default, fitdown-style), json, or csv") }