diff --git a/README.md b/README.md index 56ccdd6..ca905f3 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Export your personal nutrition, biometric, and food-log data from [Cronometer](h - **Five export endpoints** — servings (per-food log with full nutrient breakdown), nutrition (daily totals), biometrics (weight, body fat, custom metrics), exercises, and notes - **Markdown by default, JSON on demand** — narrow fitdown-style markdown reads well in chat and terminals; pass `--json` for the full structured row to pipe through `jq` -- **Date selection** — `--today`, `--days N`, or `--start YYYY-MM-DD --end YYYY-MM-DD` on every subcommand +- **Date selection** — `--since` / `--until` accepting `today`, `yesterday`, `YYYY-MM-DD`, or `Nd`/`Nw`/`Nm`/`Ny` on every subcommand - **Single static binary** — no Python or Node runtime; drop it in `~/bin/` and go - **Credentials via env** — `CRONOMETER_USERNAME` / `CRONOMETER_PASSWORD`, no config file needed - **Built for agents** — designed to be called as a terminal tool by LLMs (Claude, hermes-agent, etc.); run `crono-export prime` for a one-screen orientation (I/O contract, subcommands, jq recipes) @@ -26,7 +26,7 @@ brew install crono-export # Set credentials and try a query export CRONOMETER_USERNAME="you@example.com" export CRONOMETER_PASSWORD="…" -crono-export servings --today +crono-export servings --since today ``` ## Install @@ -82,23 +82,24 @@ The CLI logs in on every invocation; there's no token cache. Cronometer doesn't ## Usage -Every subcommand accepts the same date flags: +Every subcommand accepts the same date flags, per the [shared quantcli contract](https://github.com/quantcli/common/blob/main/CONTRACT.md#3-date-flags): | Flag | Meaning | |---|---| -| `--today` | Just today | -| `--days N` | The last N days, ending today | -| `--start YYYY-MM-DD --end YYYY-MM-DD` | Explicit window (inclusive) | +| `--since VALUE` | Inclusive lower bound | +| `--until VALUE` | Inclusive upper bound (omit for "today") | | *(none)* | Last 7 days, ending today | +`VALUE` is one of: `today`, `yesterday`, `YYYY-MM-DD`, or a relative duration like `7d`, `4w`, `6m`, `1y`. + ### Servings — per-food log One row per food item logged, with full macro and micronutrient breakdown. ```sh -crono-export servings --today -crono-export servings --days 7 -crono-export servings --start 2026-04-01 --end 2026-04-15 +crono-export servings --since today +crono-export servings --since 7d +crono-export servings --since 2026-04-01 --until 2026-04-15 ``` Default markdown output (per food, zero-valued nutrients suppressed): @@ -122,13 +123,13 @@ Default markdown output (per food, zero-valued nutrients suppressed): One row per day, totals across every food logged that day. ```sh -crono-export nutrition --days 30 +crono-export nutrition --since 30d ``` ### Biometrics — weight, body fat, custom metrics ```sh -crono-export biometrics --days 30 +crono-export biometrics --since 30d ``` ```markdown @@ -139,13 +140,13 @@ crono-export biometrics --days 30 ### Exercises ```sh -crono-export exercises --days 7 +crono-export exercises --since 7d ``` ### Notes ```sh -crono-export notes --days 30 +crono-export notes --since 30d ``` ## Output Format @@ -155,8 +156,8 @@ Default output is narrow, [Fitdown](https://github.com/datavis-tech/fitdown)-sty For programmatic use, pass `--json` (or `--format json`) to get the full structured row as a JSON array on stdout — nothing suppressed, easy to pipe through `jq`. Errors always go to stderr, so JSON output stays clean for piping. ```sh -crono-export servings --today # markdown, default -crono-export servings --today --json | jq '[.[] | {food: .FoodName, protein: .ProteinG}]' +crono-export servings --since today # markdown, default +crono-export servings --since today --json | jq '[.[] | {food: .FoodName, protein: .ProteinG}]' ``` LLM agents: run `crono-export prime` for a one-screen orientation describing both formats, all subcommands, the date flags, and `jq` recipes. diff --git a/cmd/prime.go b/cmd/prime.go index 07506d2..8441816 100644 --- a/cmd/prime.go +++ b/cmd/prime.go @@ -34,10 +34,13 @@ AUTH CRONOMETER_PASSWORD your Cronometer password DATE FLAGS (every export subcommand accepts these) - --today just today (LOCAL calendar date) - --days N last N days, ending today - --start YYYY-MM-DD --end YYYY-MM-DD explicit inclusive window - (no flag) last 7 days, ending today + --since VALUE inclusive lower bound + --until VALUE inclusive upper bound; defaults to today + VALUE: today | yesterday | YYYY-MM-DD | Nd/Nw/Nm/Ny + (no flag) last 7 days, ending today + + See https://github.com/quantcli/common/blob/main/CONTRACT.md#3-date-flags + for the cross-CLI specification. SUBCOMMANDS @@ -71,23 +74,23 @@ SUBCOMMANDS EXAMPLES # Today's macros, scannable - crono-export nutrition --today + crono-export nutrition --since today # Today's macros, parsed (numbers via tonumber) - crono-export nutrition --today --json | jq '.[] | { + crono-export nutrition --since today --json | jq '.[] | { date: .Date, kcal: (."Energy (kcal)" | tonumber), protein: (."Protein (g)" | tonumber) }' # 7-day protein total (servings is typed — no tonumber needed) - crono-export servings --days 7 --json | jq '[.[] | .ProteinG] | add' + crono-export servings --since 7d --json | jq '[.[] | .ProteinG] | add' # All foods from today's breakfast - crono-export servings --today --json | jq '[.[] | select(.Group == "Breakfast") | .FoodName]' + crono-export servings --since today --json | jq '[.[] | select(.Group == "Breakfast") | .FoodName]' # Latest weight reading in a 30-day window - crono-export biometrics --days 30 --json | jq 'map(select(.Metric == "Weight")) | sort_by(.RecordedTime) | last' + crono-export biometrics --since 30d --json | jq 'map(select(.Metric == "Weight")) | sort_by(.RecordedTime) | last' GOTCHAS - "Today" is your LOCAL calendar day, not UTC. @@ -96,8 +99,8 @@ GOTCHAS JSON values are already typed numbers. - Markdown drops zero-valued nutrients to stay readable. If you need every column (including zeros), use --json. - - Cronometer logs by calendar day; nothing here is real-time. The same - --today call moments apart returns the same data. + - Cronometer logs by calendar day; nothing here is real-time. Two + '--since today' calls moments apart return the same data. ` var primeCmd = &cobra.Command{ diff --git a/internal/cronoclient/daterange.go b/internal/cronoclient/daterange.go index c4ac69b..082eea3 100644 --- a/internal/cronoclient/daterange.go +++ b/internal/cronoclient/daterange.go @@ -5,6 +5,7 @@ package cronoclient import ( "fmt" + "strings" "time" "github.com/spf13/cobra" @@ -15,71 +16,97 @@ const dateLayout = "2006-01-02" // DateRange is an inclusive [Start, End] window. Only the calendar date // (YYYY-MM-DD) of each endpoint is sent to Cronometer's export endpoints, // so the time-of-day and zone on these values don't round-trip — but the -// calendar date is resolved in the user's local zone so that --today +// calendar date is resolved in the user's local zone so that "today" // matches the day the user sees in the Cronometer UI. type DateRange struct { Start time.Time End time.Time } -// AddDateRangeFlags binds --start, --end, --days, --today on cmd. Each -// subcommand calls this so they all share the same flag vocabulary. +// AddDateRangeFlags binds --since and --until on cmd. Each subcommand calls +// this so they all share the same flag vocabulary, per the quantcli shared +// contract: https://github.com/quantcli/common/blob/main/CONTRACT.md#3-date-flags. func AddDateRangeFlags(cmd *cobra.Command) { - cmd.Flags().String("start", "", "start date (YYYY-MM-DD)") - cmd.Flags().String("end", "", "end date (YYYY-MM-DD), defaults to today") - cmd.Flags().Int("days", 0, "convenience: last N days ending today") - cmd.Flags().Bool("today", false, "convenience: today only") + cmd.Flags().String("since", "", + "Filter on or after date (today, yesterday, YYYY-MM-DD, or Nd/Nw/Nm/Ny; default 7d)") + cmd.Flags().String("until", "", + "Filter through date, inclusive (today, yesterday, YYYY-MM-DD, or Nd/Nw/Nm/Ny; default today)") } -// ParseDateRangeFromFlags reads the date-range flags off cmd and resolves -// them into a concrete DateRange. Default when no flags are passed: the -// last 7 days ending today. "Today" is the user's local calendar day. +// ParseDateRangeFromFlags reads --since/--until off cmd and resolves them +// into a concrete DateRange. Default when neither flag is set: the last +// 7 days ending today. All values are interpreted in the user's local +// calendar. func ParseDateRangeFromFlags(cmd *cobra.Command) (DateRange, error) { - startStr, _ := cmd.Flags().GetString("start") - endStr, _ := cmd.Flags().GetString("end") - days, _ := cmd.Flags().GetInt("days") - today, _ := cmd.Flags().GetBool("today") - return resolveDateRange(startStr, endStr, days, today, time.Now()) + sinceStr, _ := cmd.Flags().GetString("since") + untilStr, _ := cmd.Flags().GetString("until") + return resolveDateRange(sinceStr, untilStr, time.Now()) } -func resolveDateRange(startStr, endStr string, days int, today bool, ref time.Time) (DateRange, error) { +func resolveDateRange(sinceStr, untilStr string, ref time.Time) (DateRange, error) { y, m, d := ref.Date() - now := time.Date(y, m, d, 0, 0, 0, 0, ref.Location()) - var start, end time.Time + today := time.Date(y, m, d, 0, 0, 0, 0, ref.Location()) - switch { - case today: - start, end = now, now - case days > 0: - end = now - start = now.AddDate(0, 0, -(days - 1)) - case startStr == "" && endStr == "": - end = now - start = now.AddDate(0, 0, -6) - default: - var err error - if startStr != "" { - start, err = time.ParseInLocation(dateLayout, startStr, ref.Location()) - if err != nil { - return DateRange{}, fmt.Errorf("bad --start: %w", err) - } - } - if endStr != "" { - end, err = time.ParseInLocation(dateLayout, endStr, ref.Location()) - if err != nil { - return DateRange{}, fmt.Errorf("bad --end: %w", err) - } - } else { - end = now - } - if start.IsZero() { - start = end - } + since, err := parseDateValue(sinceStr, today) + if err != nil { + return DateRange{}, fmt.Errorf("bad --since: %w", err) + } + until, err := parseDateValue(untilStr, today) + if err != nil { + return DateRange{}, fmt.Errorf("bad --until: %w", err) + } + + if since.IsZero() && until.IsZero() { + // Default window: last 7 days ending today. + return DateRange{Start: today.AddDate(0, 0, -6), End: today}, nil + } + if until.IsZero() { + until = today + } + if since.IsZero() { + since = until + } + if until.Before(since) { + return DateRange{}, fmt.Errorf("--until (%s) is before --since (%s)", + until.Format(dateLayout), since.Format(dateLayout)) } + return DateRange{Start: since, End: until}, nil +} - if end.Before(start) { - return DateRange{}, fmt.Errorf("--end (%s) is before --start (%s)", - end.Format(dateLayout), start.Format(dateLayout)) +// parseDateValue parses a --since or --until value per the 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. The today reference is passed in for testability. +func parseDateValue(s string, today time.Time) (time.Time, error) { + if s == "" { + return time.Time{}, nil + } + switch strings.ToLower(s) { + case "today": + return today, nil + case "yesterday": + return today.AddDate(0, 0, -1), nil + } + if t, err := time.ParseInLocation(dateLayout, s, today.Location()); err == nil { + return t, nil + } + if len(s) < 2 { + 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 date %q (use YYYY-MM-DD, today, yesterday, or Nd/Nw/Nm/Ny)", s) + } + switch s[len(s)-1] { + case 'd': + return today.AddDate(0, 0, -n), nil + case 'w': + return today.AddDate(0, 0, -n*7), nil + case 'm': + return today.AddDate(0, -n, 0), nil + case 'y': + return today.AddDate(-n, 0, 0), nil + default: + return time.Time{}, fmt.Errorf("invalid date unit %q: use d, w, m, or y", string(s[len(s)-1])) } - return DateRange{Start: start, End: end}, nil }