Skip to content

Commit a5c227d

Browse files
DTTerastarclaude
andcommitted
feat!: harmonize date flags — replace --today/--days/--start/--end with --since/--until
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) <noreply@anthropic.com>
1 parent 38f1fcd commit a5c227d

3 files changed

Lines changed: 107 additions & 76 deletions

File tree

README.md

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ Export your personal nutrition, biometric, and food-log data from [Cronometer](h
1111

1212
- **Five export endpoints** — servings (per-food log with full nutrient breakdown), nutrition (daily totals), biometrics (weight, body fat, custom metrics), exercises, and notes
1313
- **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`
14-
- **Date selection**`--today`, `--days N`, or `--start YYYY-MM-DD --end YYYY-MM-DD` on every subcommand
14+
- **Date selection**`--since` / `--until` accepting `today`, `yesterday`, `YYYY-MM-DD`, or `Nd`/`Nw`/`Nm`/`Ny` on every subcommand
1515
- **Single static binary** — no Python or Node runtime; drop it in `~/bin/` and go
1616
- **Credentials via env**`CRONOMETER_USERNAME` / `CRONOMETER_PASSWORD`, no config file needed
1717
- **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
2626
# Set credentials and try a query
2727
export CRONOMETER_USERNAME="you@example.com"
2828
export CRONOMETER_PASSWORD=""
29-
crono-export servings --today
29+
crono-export servings --since today
3030
```
3131

3232
## Install
@@ -82,23 +82,24 @@ The CLI logs in on every invocation; there's no token cache. Cronometer doesn't
8282

8383
## Usage
8484

85-
Every subcommand accepts the same date flags:
85+
Every subcommand accepts the same date flags, per the [shared quantcli contract](https://github.com/quantcli/common/blob/main/CONTRACT.md#3-date-flags):
8686

8787
| Flag | Meaning |
8888
|---|---|
89-
| `--today` | Just today |
90-
| `--days N` | The last N days, ending today |
91-
| `--start YYYY-MM-DD --end YYYY-MM-DD` | Explicit window (inclusive) |
89+
| `--since VALUE` | Inclusive lower bound |
90+
| `--until VALUE` | Inclusive upper bound (omit for "today") |
9291
| *(none)* | Last 7 days, ending today |
9392

93+
`VALUE` is one of: `today`, `yesterday`, `YYYY-MM-DD`, or a relative duration like `7d`, `4w`, `6m`, `1y`.
94+
9495
### Servings — per-food log
9596

9697
One row per food item logged, with full macro and micronutrient breakdown.
9798

9899
```sh
99-
crono-export servings --today
100-
crono-export servings --days 7
101-
crono-export servings --start 2026-04-01 --end 2026-04-15
100+
crono-export servings --since today
101+
crono-export servings --since 7d
102+
crono-export servings --since 2026-04-01 --until 2026-04-15
102103
```
103104

104105
Default markdown output (per food, zero-valued nutrients suppressed):
@@ -122,13 +123,13 @@ Default markdown output (per food, zero-valued nutrients suppressed):
122123
One row per day, totals across every food logged that day.
123124

124125
```sh
125-
crono-export nutrition --days 30
126+
crono-export nutrition --since 30d
126127
```
127128

128129
### Biometrics — weight, body fat, custom metrics
129130

130131
```sh
131-
crono-export biometrics --days 30
132+
crono-export biometrics --since 30d
132133
```
133134

134135
```markdown
@@ -139,13 +140,13 @@ crono-export biometrics --days 30
139140
### Exercises
140141

141142
```sh
142-
crono-export exercises --days 7
143+
crono-export exercises --since 7d
143144
```
144145

145146
### Notes
146147

147148
```sh
148-
crono-export notes --days 30
149+
crono-export notes --since 30d
149150
```
150151

151152
## Output Format
@@ -155,8 +156,8 @@ Default output is narrow, [Fitdown](https://github.com/datavis-tech/fitdown)-sty
155156
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.
156157

157158
```sh
158-
crono-export servings --today # markdown, default
159-
crono-export servings --today --json | jq '[.[] | {food: .FoodName, protein: .ProteinG}]'
159+
crono-export servings --since today # markdown, default
160+
crono-export servings --since today --json | jq '[.[] | {food: .FoodName, protein: .ProteinG}]'
160161
```
161162

162163
LLM agents: run `crono-export prime` for a one-screen orientation describing both formats, all subcommands, the date flags, and `jq` recipes.

cmd/prime.go

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,13 @@ AUTH
3434
CRONOMETER_PASSWORD your Cronometer password
3535
3636
DATE FLAGS (every export subcommand accepts these)
37-
--today just today (LOCAL calendar date)
38-
--days N last N days, ending today
39-
--start YYYY-MM-DD --end YYYY-MM-DD explicit inclusive window
40-
(no flag) last 7 days, ending today
37+
--since VALUE inclusive lower bound
38+
--until VALUE inclusive upper bound; defaults to today
39+
VALUE: today | yesterday | YYYY-MM-DD | Nd/Nw/Nm/Ny
40+
(no flag) last 7 days, ending today
41+
42+
See https://github.com/quantcli/common/blob/main/CONTRACT.md#3-date-flags
43+
for the cross-CLI specification.
4144
4245
SUBCOMMANDS
4346
@@ -71,23 +74,23 @@ SUBCOMMANDS
7174
EXAMPLES
7275
7376
# Today's macros, scannable
74-
crono-export nutrition --today
77+
crono-export nutrition --since today
7578
7679
# Today's macros, parsed (numbers via tonumber)
77-
crono-export nutrition --today --json | jq '.[] | {
80+
crono-export nutrition --since today --json | jq '.[] | {
7881
date: .Date,
7982
kcal: (."Energy (kcal)" | tonumber),
8083
protein: (."Protein (g)" | tonumber)
8184
}'
8285
8386
# 7-day protein total (servings is typed — no tonumber needed)
84-
crono-export servings --days 7 --json | jq '[.[] | .ProteinG] | add'
87+
crono-export servings --since 7d --json | jq '[.[] | .ProteinG] | add'
8588
8689
# All foods from today's breakfast
87-
crono-export servings --today --json | jq '[.[] | select(.Group == "Breakfast") | .FoodName]'
90+
crono-export servings --since today --json | jq '[.[] | select(.Group == "Breakfast") | .FoodName]'
8891
8992
# Latest weight reading in a 30-day window
90-
crono-export biometrics --days 30 --json | jq 'map(select(.Metric == "Weight")) | sort_by(.RecordedTime) | last'
93+
crono-export biometrics --since 30d --json | jq 'map(select(.Metric == "Weight")) | sort_by(.RecordedTime) | last'
9194
9295
GOTCHAS
9396
- "Today" is your LOCAL calendar day, not UTC.
@@ -96,8 +99,8 @@ GOTCHAS
9699
JSON values are already typed numbers.
97100
- Markdown drops zero-valued nutrients to stay readable. If you need
98101
every column (including zeros), use --json.
99-
- Cronometer logs by calendar day; nothing here is real-time. The same
100-
--today call moments apart returns the same data.
102+
- Cronometer logs by calendar day; nothing here is real-time. Two
103+
'--since today' calls moments apart return the same data.
101104
`
102105

103106
var primeCmd = &cobra.Command{

internal/cronoclient/daterange.go

Lines changed: 77 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package cronoclient
55

66
import (
77
"fmt"
8+
"strings"
89
"time"
910

1011
"github.com/spf13/cobra"
@@ -15,71 +16,97 @@ const dateLayout = "2006-01-02"
1516
// DateRange is an inclusive [Start, End] window. Only the calendar date
1617
// (YYYY-MM-DD) of each endpoint is sent to Cronometer's export endpoints,
1718
// so the time-of-day and zone on these values don't round-trip — but the
18-
// calendar date is resolved in the user's local zone so that --today
19+
// calendar date is resolved in the user's local zone so that "today"
1920
// matches the day the user sees in the Cronometer UI.
2021
type DateRange struct {
2122
Start time.Time
2223
End time.Time
2324
}
2425

25-
// AddDateRangeFlags binds --start, --end, --days, --today on cmd. Each
26-
// subcommand calls this so they all share the same flag vocabulary.
26+
// AddDateRangeFlags binds --since and --until on cmd. Each subcommand calls
27+
// this so they all share the same flag vocabulary, per the quantcli shared
28+
// contract: https://github.com/quantcli/common/blob/main/CONTRACT.md#3-date-flags.
2729
func AddDateRangeFlags(cmd *cobra.Command) {
28-
cmd.Flags().String("start", "", "start date (YYYY-MM-DD)")
29-
cmd.Flags().String("end", "", "end date (YYYY-MM-DD), defaults to today")
30-
cmd.Flags().Int("days", 0, "convenience: last N days ending today")
31-
cmd.Flags().Bool("today", false, "convenience: today only")
30+
cmd.Flags().String("since", "",
31+
"Filter on or after date (today, yesterday, YYYY-MM-DD, or Nd/Nw/Nm/Ny; default 7d)")
32+
cmd.Flags().String("until", "",
33+
"Filter through date, inclusive (today, yesterday, YYYY-MM-DD, or Nd/Nw/Nm/Ny; default today)")
3234
}
3335

34-
// ParseDateRangeFromFlags reads the date-range flags off cmd and resolves
35-
// them into a concrete DateRange. Default when no flags are passed: the
36-
// last 7 days ending today. "Today" is the user's local calendar day.
36+
// ParseDateRangeFromFlags reads --since/--until off cmd and resolves them
37+
// into a concrete DateRange. Default when neither flag is set: the last
38+
// 7 days ending today. All values are interpreted in the user's local
39+
// calendar.
3740
func ParseDateRangeFromFlags(cmd *cobra.Command) (DateRange, error) {
38-
startStr, _ := cmd.Flags().GetString("start")
39-
endStr, _ := cmd.Flags().GetString("end")
40-
days, _ := cmd.Flags().GetInt("days")
41-
today, _ := cmd.Flags().GetBool("today")
42-
return resolveDateRange(startStr, endStr, days, today, time.Now())
41+
sinceStr, _ := cmd.Flags().GetString("since")
42+
untilStr, _ := cmd.Flags().GetString("until")
43+
return resolveDateRange(sinceStr, untilStr, time.Now())
4344
}
4445

45-
func resolveDateRange(startStr, endStr string, days int, today bool, ref time.Time) (DateRange, error) {
46+
func resolveDateRange(sinceStr, untilStr string, ref time.Time) (DateRange, error) {
4647
y, m, d := ref.Date()
47-
now := time.Date(y, m, d, 0, 0, 0, 0, ref.Location())
48-
var start, end time.Time
48+
today := time.Date(y, m, d, 0, 0, 0, 0, ref.Location())
4949

50-
switch {
51-
case today:
52-
start, end = now, now
53-
case days > 0:
54-
end = now
55-
start = now.AddDate(0, 0, -(days - 1))
56-
case startStr == "" && endStr == "":
57-
end = now
58-
start = now.AddDate(0, 0, -6)
59-
default:
60-
var err error
61-
if startStr != "" {
62-
start, err = time.ParseInLocation(dateLayout, startStr, ref.Location())
63-
if err != nil {
64-
return DateRange{}, fmt.Errorf("bad --start: %w", err)
65-
}
66-
}
67-
if endStr != "" {
68-
end, err = time.ParseInLocation(dateLayout, endStr, ref.Location())
69-
if err != nil {
70-
return DateRange{}, fmt.Errorf("bad --end: %w", err)
71-
}
72-
} else {
73-
end = now
74-
}
75-
if start.IsZero() {
76-
start = end
77-
}
50+
since, err := parseDateValue(sinceStr, today)
51+
if err != nil {
52+
return DateRange{}, fmt.Errorf("bad --since: %w", err)
53+
}
54+
until, err := parseDateValue(untilStr, today)
55+
if err != nil {
56+
return DateRange{}, fmt.Errorf("bad --until: %w", err)
57+
}
58+
59+
if since.IsZero() && until.IsZero() {
60+
// Default window: last 7 days ending today.
61+
return DateRange{Start: today.AddDate(0, 0, -6), End: today}, nil
62+
}
63+
if until.IsZero() {
64+
until = today
65+
}
66+
if since.IsZero() {
67+
since = until
68+
}
69+
if until.Before(since) {
70+
return DateRange{}, fmt.Errorf("--until (%s) is before --since (%s)",
71+
until.Format(dateLayout), since.Format(dateLayout))
7872
}
73+
return DateRange{Start: since, End: until}, nil
74+
}
7975

80-
if end.Before(start) {
81-
return DateRange{}, fmt.Errorf("--end (%s) is before --start (%s)",
82-
end.Format(dateLayout), start.Format(dateLayout))
76+
// parseDateValue parses a --since or --until value per the shared contract:
77+
// "today", "yesterday", absolute YYYY-MM-DD, or relative Nd/Nw/Nm/Ny.
78+
// Returns local midnight for the target day; empty string yields the zero
79+
// time. The today reference is passed in for testability.
80+
func parseDateValue(s string, today time.Time) (time.Time, error) {
81+
if s == "" {
82+
return time.Time{}, nil
83+
}
84+
switch strings.ToLower(s) {
85+
case "today":
86+
return today, nil
87+
case "yesterday":
88+
return today.AddDate(0, 0, -1), nil
89+
}
90+
if t, err := time.ParseInLocation(dateLayout, s, today.Location()); err == nil {
91+
return t, nil
92+
}
93+
if len(s) < 2 {
94+
return time.Time{}, fmt.Errorf("invalid date %q (use YYYY-MM-DD, today, yesterday, or Nd/Nw/Nm/Ny)", s)
95+
}
96+
n := 0
97+
if _, err := fmt.Sscanf(s[:len(s)-1], "%d", &n); err != nil {
98+
return time.Time{}, fmt.Errorf("invalid date %q (use YYYY-MM-DD, today, yesterday, or Nd/Nw/Nm/Ny)", s)
99+
}
100+
switch s[len(s)-1] {
101+
case 'd':
102+
return today.AddDate(0, 0, -n), nil
103+
case 'w':
104+
return today.AddDate(0, 0, -n*7), nil
105+
case 'm':
106+
return today.AddDate(0, -n, 0), nil
107+
case 'y':
108+
return today.AddDate(-n, 0, 0), nil
109+
default:
110+
return time.Time{}, fmt.Errorf("invalid date unit %q: use d, w, m, or y", string(s[len(s)-1]))
83111
}
84-
return DateRange{Start: start, End: end}, nil
85112
}

0 commit comments

Comments
 (0)