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") }