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
31 changes: 16 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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):
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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.
Expand Down
25 changes: 14 additions & 11 deletions cmd/prime.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand All @@ -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{
Expand Down
127 changes: 77 additions & 50 deletions internal/cronoclient/daterange.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package cronoclient

import (
"fmt"
"strings"
"time"

"github.com/spf13/cobra"
Expand All @@ -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
}
Loading