From a5c227dc39ab68bc58dcf6aa47d2958c3f035ad2 Mon Sep 17 00:00:00 2001
From: DTTerastar
Date: Sat, 25 Apr 2026 17:52:47 -0400
Subject: [PATCH] =?UTF-8?q?feat!:=20harmonize=20date=20flags=20=E2=80=94?=
=?UTF-8?q?=20replace=20--today/--days/--start/--end=20with=20--since/--un?=
=?UTF-8?q?til?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
BREAKING: --today, --days, --start, --end are removed. Replaced by
--since and --until per 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 (defaults to today)
VALUE: today | yesterday | YYYY-MM-DD | Nd/Nw/Nm/Ny
Migration table:
--today → --since today
--days N → --since Nd
--start X --end Y → --since X --until Y
(no flag — last 7 days) → unchanged
Updated prime, README, and the shared cobra flag binding in cronoclient
to match. The other quantcli CLIs (liftoff, withings) already use
--since; this PR finishes the harmonization.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
README.md | 31 ++++----
cmd/prime.go | 25 +++---
internal/cronoclient/daterange.go | 127 ++++++++++++++++++------------
3 files changed, 107 insertions(+), 76 deletions(-)
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
}