Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions cmd/activity.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import (
"os"
"sort"
"strconv"
"time"

"github.com/quantcli/withings-export-cli/internal/client"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -43,6 +42,7 @@ type activityResponse struct {
var (
activityFormatFlag string
activitySinceFlag string
activityUntilFlag string
)

var activityCmd = &cobra.Command{
Expand All @@ -53,14 +53,18 @@ 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"

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()
Expand Down Expand Up @@ -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")
}
15 changes: 11 additions & 4 deletions cmd/intraday.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ type intradayResponse struct {
var (
intradayFormatFlag string
intradaySinceFlag string
intradayUntilFlag string
)

var intradayCmd = &cobra.Command{
Expand All @@ -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{}
Expand Down Expand Up @@ -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")
}
11 changes: 9 additions & 2 deletions cmd/measurements.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ type measurementRow struct {
var (
measurementsFormatFlag string
measurementsSinceFlag string
measurementsUntilFlag string
measurementsTypesFlag string
)

Expand All @@ -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)
}
Expand Down Expand Up @@ -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", "",
Expand Down
84 changes: 70 additions & 14 deletions cmd/shared.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
14 changes: 10 additions & 4 deletions cmd/sleep.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ type sleepResponse struct {
var (
sleepFormatFlag string
sleepSinceFlag string
sleepUntilFlag string
sleepDeriveFlag bool
)

Expand All @@ -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," +
Expand All @@ -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()
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down
11 changes: 9 additions & 2 deletions cmd/workouts.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ type workoutsResponse struct {
var (
workoutsFormatFlag string
workoutsSinceFlag string
workoutsUntilFlag string
)

var workoutsCmd = &cobra.Command{
Expand All @@ -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," +
Expand All @@ -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()
Expand Down Expand Up @@ -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")
}
Loading