|
| 1 | +# Cronometer Protocol Spec — for clean-room client (Phase 1) |
| 2 | + |
| 3 | +Status: **draft, Phase 1 of [QUA-12](https://github.com/quantcli/common) plan v4** |
| 4 | + |
| 5 | +This document specifies the surface that an MIT-licensed internal Cronometer |
| 6 | +client must reimplement so that `crono-export-cli` can drop its dependency |
| 7 | +on the GPL-2.0 `github.com/jrmycanady/gocronometer` package. |
| 8 | + |
| 9 | +## Clean-room ground rules |
| 10 | + |
| 11 | +The implementer **must not consult `gocronometer`'s source code** while |
| 12 | +working from this spec. The only permitted inputs to the reimplementation |
| 13 | +are: |
| 14 | + |
| 15 | +1. This spec document. |
| 16 | +2. `gocronometer`'s **public API surface** — exported names and Go type |
| 17 | + signatures, as already named and called from `crono-export-cli`'s own |
| 18 | + MIT-licensed code (`internal/cronoclient/client.go`, `cmd/format.go`). |
| 19 | +3. Cronometer's **observable HTTP behaviour** — what the Cronometer web |
| 20 | + app sends and receives in a browser session against |
| 21 | + `https://cronometer.com`, captured as HAR / proxy logs against a real |
| 22 | + test account. |
| 23 | + |
| 24 | +`gocronometer`'s source files, internal helpers, comments, request |
| 25 | +construction, and parsing logic are **out of scope** and must not be |
| 26 | +read or copied. If a question can only be answered by reading |
| 27 | +`gocronometer` source, capture the answer empirically from a HAR |
| 28 | +instead. |
| 29 | + |
| 30 | +This document was authored without consulting `gocronometer` source. |
| 31 | + |
| 32 | +## Where the spec comes from |
| 33 | + |
| 34 | +Everything in the "Public surface we consume" section below is derived |
| 35 | +from `crono-export-cli`'s own code — that is, the call sites and field |
| 36 | +references already present in our MIT-licensed source. Nothing in this |
| 37 | +document was copied from `gocronometer`. |
| 38 | + |
| 39 | +Wire-level details (URL paths, request bodies, exact response CSV |
| 40 | +headers, cookie names) are marked **TBD** wherever they cannot be |
| 41 | +established from public sources, and must be filled in empirically |
| 42 | +during Phase 3 by HAR capture against a real Cronometer account. |
| 43 | + |
| 44 | +## Public surface we consume |
| 45 | + |
| 46 | +`crono-export-cli` currently uses the following exported names from |
| 47 | +`github.com/jrmycanady/gocronometer`. The clean-room replacement must |
| 48 | +provide equivalents (under different package and type names — see |
| 49 | +"Naming" below) sufficient to delete every reference to the upstream |
| 50 | +package from our codebase. |
| 51 | + |
| 52 | +### Constructor and session |
| 53 | + |
| 54 | +| Upstream call | Used in | Purpose | |
| 55 | +| -------------------------------------- | ------------------------------------------ | ---------------------------------------- | |
| 56 | +| `gocronometer.NewClient(httpClient)` | `internal/cronoclient/client.go` (Login) | Construct a client. We pass `nil` today. | |
| 57 | +| `(*Client).Login(ctx, user, pass)` | `internal/cronoclient/client.go` (Login) | Username/password session login. | |
| 58 | +| `(*Client).Logout(ctx)` | `internal/cronoclient/client.go` (Logout) | Tear down the session, best-effort. | |
| 59 | + |
| 60 | +Auth model the replacement must support: |
| 61 | + |
| 62 | +- Credentials are read from environment variables `CRONOMETER_USERNAME` |
| 63 | + and `CRONOMETER_PASSWORD`. The CLI logs in fresh on every invocation |
| 64 | + and does not cache tokens. |
| 65 | +- There is no SSO / API-token / OAuth pathway exposed to individual |
| 66 | + Cronometer users today (per the project README, last verified |
| 67 | + 2026-05). Password POST is the only available auth mode. |
| 68 | +- Session lifetime, cookie names, anti-forgery tokens, redirect chain: |
| 69 | + **TBD via HAR**. |
| 70 | + |
| 71 | +### Export operations |
| 72 | + |
| 73 | +| Upstream call | Returns | Used by | |
| 74 | +| ------------------------------------------------------------------- | ---------------------------------------- | -------------------------------- | |
| 75 | +| `ExportServingsParsedWithLocation(ctx, start, end, loc)` | `gocronometer.ServingRecords` | `cronoclient.Client.Servings` | |
| 76 | +| `ExportExercisesParsedWithLocation(ctx, start, end, loc)` | `gocronometer.ExerciseRecords` | `cronoclient.Client.Exercises` | |
| 77 | +| `ExportBiometricRecordsParsedWithLocation(ctx, start, end, loc)` | `gocronometer.BiometricRecords` | `cronoclient.Client.Biometrics` | |
| 78 | +| `ExportDailyNutrition(ctx, start, end)` | raw CSV `string` | `cronoclient.Client.Nutrition` | |
| 79 | +| `ExportNotes(ctx, start, end)` | raw CSV `string` | `cronoclient.Client.Notes` | |
| 80 | + |
| 81 | +Parameter conventions (the only ones we depend on): |
| 82 | + |
| 83 | +- `ctx context.Context` — cancellation / timeout. Replacement must |
| 84 | + honour `ctx.Done()` on the underlying HTTP request. |
| 85 | +- `start, end time.Time` — inclusive `[start, end]` window. Only the |
| 86 | + calendar date (`YYYY-MM-DD`) of each endpoint is significant on the |
| 87 | + wire. Time of day and zone on these values do **not** round-trip. |
| 88 | + Date strings on the wire use the user's local calendar. |
| 89 | +- `loc *time.Location` — the parsed `RecordedTime` on rows must be |
| 90 | + produced in this zone. We always pass `time.Local`. |
| 91 | +- CSV-only methods (`ExportDailyNutrition`, `ExportNotes`) do not |
| 92 | + accept a `*time.Location`. The CSV `Date` / `Day` columns are |
| 93 | + rendered verbatim as Cronometer emits them. |
| 94 | + |
| 95 | +### Record types we read |
| 96 | + |
| 97 | +These are the only fields `crono-export-cli` reads off |
| 98 | +`gocronometer`'s typed record types (proof: `cmd/format.go` and |
| 99 | +`cmd/auth.go` are the only consumers). The replacement must expose |
| 100 | +identically-named fields with these Go types on its own record types |
| 101 | +so the `cmd/format.go` reflection-driven renderer continues to work |
| 102 | +unchanged (or so that a thin compatibility shim does). |
| 103 | + |
| 104 | +`ServingRecord` (one row per food logged, full nutrient breakdown): |
| 105 | + |
| 106 | +| Field | Go type | Notes | |
| 107 | +| ----------------- | ----------- | ------------------------------------------------------ | |
| 108 | +| `RecordedTime` | `time.Time` | Parsed in caller-supplied `*time.Location`. | |
| 109 | +| `Group` | `string` | Meal group / category bucket (e.g., "Breakfast"). | |
| 110 | +| `FoodName` | `string` | Free-form. | |
| 111 | +| `QuantityValue` | `float64` | Serving size numeric. | |
| 112 | +| `QuantityUnits` | `string` | Serving size unit ("g", "ml", "cup", …). | |
| 113 | +| `Category` | `string` | (Currently skipped from markdown rendering.) | |
| 114 | +| many `float64` | `float64` | Nutrient columns; field names follow `<Name><Unit>`. | |
| 115 | + |
| 116 | +Nutrient field naming convention (used by `cmd/format.go`'s |
| 117 | +`strippedSuffix`): the upstream type's Go field names embed the unit |
| 118 | +as a suffix. The renderer recognises these suffixes (longest-first): |
| 119 | + |
| 120 | +| Field suffix | Display unit | |
| 121 | +| ------------ | ------------ | |
| 122 | +| `Kcal` | `kcal` | |
| 123 | +| `Ug` | `µg` | |
| 124 | +| `Mg` | `mg` | |
| 125 | +| `UI` | `IU` | |
| 126 | +| `G` | `g` | |
| 127 | + |
| 128 | +So `EnergyKcal` → "Energy: X kcal", `ProteinG` → "Protein: X g", |
| 129 | +`B12Ug` → "B12: X µg", `VitaminAUI` → "Vitamin A: X IU". The |
| 130 | +replacement record type's nutrient fields must follow the same |
| 131 | +suffix convention so the existing renderer keeps working without |
| 132 | +changes. The exact list of nutrient columns must be captured from |
| 133 | +the live CSV header during Phase 3 (Cronometer adds new nutrient |
| 134 | +columns periodically). |
| 135 | + |
| 136 | +`BiometricRecord` (weight, body fat, blood pressure, custom metrics): |
| 137 | + |
| 138 | +| Field | Go type | Notes | |
| 139 | +| -------------- | ----------- | ------------------------------------------------ | |
| 140 | +| `RecordedTime` | `time.Time` | Parsed in caller-supplied `*time.Location`. | |
| 141 | +| `Metric` | `string` | Metric name (e.g., "Weight"). | |
| 142 | +| `Amount` | `float64` | Numeric value. | |
| 143 | +| `Unit` | `string` | Unit string emitted by Cronometer (e.g., "lbs"). | |
| 144 | + |
| 145 | +`ExerciseRecord` (logged exercises): |
| 146 | + |
| 147 | +| Field | Go type | Notes | |
| 148 | +| ---------------- | ----------- | ---------------------------------------------- | |
| 149 | +| `RecordedTime` | `time.Time` | Parsed in caller-supplied `*time.Location`. | |
| 150 | +| `Exercise` | `string` | Exercise name. | |
| 151 | +| `Minutes` | `float64` | Duration. Zero when unset by the user. | |
| 152 | +| `CaloriesBurned` | `float64` | Reported energy burn. Zero when unset. | |
| 153 | +| `Group` | `string` | Optional tag/category. | |
| 154 | + |
| 155 | +Aliases used in callers: |
| 156 | + |
| 157 | +- `gocronometer.ServingRecords` is the slice form |
| 158 | + `[]gocronometer.ServingRecord`. Same for `BiometricRecords` and |
| 159 | + `ExerciseRecords`. |
| 160 | + |
| 161 | +### CSV-only endpoints we use raw |
| 162 | + |
| 163 | +For `Nutrition` and `Notes`, our existing `internal/cronoclient/client.go` |
| 164 | +treats the upstream return as an opaque CSV string and parses it with |
| 165 | +`encoding/csv` into `[]map[string]string`. We do not use any upstream |
| 166 | +typed parser for these. The replacement must either: |
| 167 | + |
| 168 | +- return CSV verbatim from those endpoints (same `string` shape), or |
| 169 | +- decode to `[]map[string]string` directly with the same column |
| 170 | + semantics described below. |
| 171 | + |
| 172 | +Notes column tolerance: `cmd/format.go`'s `renderNotes` picks the |
| 173 | +date column from `Day` or `Date` (in that order) and the note column |
| 174 | +from `Note`, `Notes`, or `Comment`, with a fallback to dumping all |
| 175 | +non-empty fields. The replacement must preserve at least one of each |
| 176 | +group's column names so this fallback is rarely triggered. |
| 177 | + |
| 178 | +Known nutrition gotcha: per the prime text, the `Day` field on |
| 179 | +`ServingRecord` is always null and the renderer uses `RecordedTime` |
| 180 | +instead; the replacement must preserve that behaviour (i.e., do not |
| 181 | +silently start populating `Day` with a stale value, since downstream |
| 182 | +LLM agents have been instructed it is null). |
| 183 | + |
| 184 | +## Datetime semantics |
| 185 | + |
| 186 | +- All `RecordedTime` values are parsed using the caller-supplied |
| 187 | + `*time.Location` (we always pass `time.Local`). They must reflect |
| 188 | + the calendar moment the Cronometer UI shows for that row, not UTC. |
| 189 | +- The `--since` / `--until` flags parse to local-midnight `time.Time` |
| 190 | + values (see `internal/cronoclient/daterange.go`); only the calendar |
| 191 | + date (`YYYY-MM-DD`) of each endpoint of the inclusive window matters |
| 192 | + on the wire. |
| 193 | +- CSV `Date` / `Day` columns are passed through as Cronometer emits |
| 194 | + them — no re-parsing in our client today. |
| 195 | + |
| 196 | +These semantics are codified in [`quantcli/common`'s `CONTRACT.md` |
| 197 | +§3 Date flags](https://github.com/quantcli/common/blob/main/CONTRACT.md#3-date-flags) |
| 198 | +and must continue to hold post-rewrite. |
| 199 | + |
| 200 | +## Wire layer — TBD during Phase 3 |
| 201 | + |
| 202 | +The items below cannot be specified without hitting the real |
| 203 | +`cronometer.com` endpoints. They will be captured empirically in |
| 204 | +Phase 3 against a real Cronometer test account (board-provided HAR |
| 205 | +or DT-supplied credentials). They are listed here so the Phase 3 |
| 206 | +implementer has a checklist; nothing here may be answered by |
| 207 | +reading `gocronometer` source. |
| 208 | + |
| 209 | +| Concern | Resolution route | |
| 210 | +| ------------------------------------------------------ | -------------------------------------- | |
| 211 | +| Login URL, method, request body shape | HAR: cronometer.com login form | |
| 212 | +| Anti-forgery / CSRF token bootstrap | HAR: first page load | |
| 213 | +| Session cookie name(s) and `Set-Cookie` flags | HAR: post-login response | |
| 214 | +| Logout URL, request shape, expected response | HAR: logout click | |
| 215 | +| Servings export URL + query params + date format | HAR: "Export" servings flow | |
| 216 | +| Exercises export URL + params | HAR: "Export" exercises flow | |
| 217 | +| Biometrics export URL + params | HAR: "Export" biometrics flow | |
| 218 | +| Daily-nutrition export URL + params | HAR: "Export" daily-nutrition flow | |
| 219 | +| Notes export URL + params | HAR: "Export" notes flow | |
| 220 | +| Response content types (CSV vs JSON; charset) | HAR: response headers | |
| 221 | +| Exact CSV header column names per endpoint | HAR: response bodies | |
| 222 | +| Time-of-day format inside `RecordedTime` parsing | HAR: response body shape | |
| 223 | +| Pagination / cursor behaviour, if any | HAR: long-window export | |
| 224 | +| Rate-limit and retry-after semantics | HAR: forced rapid replays | |
| 225 | +| Error response shapes (auth fail, range too long, …) | HAR: deliberate-failure runs | |
| 226 | + |
| 227 | +The Phase 3 implementer must record these HAR samples under |
| 228 | +`internal/cronoclient/testdata/fixtures/` (golden files) so the |
| 229 | +clean-room client's tests run hermetically with no live network. |
| 230 | + |
| 231 | +## Naming for the replacement |
| 232 | + |
| 233 | +The replacement package must not be named `gocronometer` and must |
| 234 | +not re-export gocronometer-style identifiers verbatim. Suggested |
| 235 | +shape (final naming decided in Phase 3 PR review): |
| 236 | + |
| 237 | +- Package path: `github.com/quantcli/crono-export-cli/internal/cronoapi` |
| 238 | + (or a similar name under `internal/`). Keep the existing |
| 239 | + `internal/cronoclient` wrapper layer intact so call sites in |
| 240 | + `cmd/*.go` don't churn. |
| 241 | +- Client type: `cronoapi.Client` (or similar) with `NewClient`, |
| 242 | + `Login`, `Logout`. |
| 243 | +- Record types: `cronoapi.ServingRecord`, `cronoapi.BiometricRecord`, |
| 244 | + `cronoapi.ExerciseRecord`, with collection aliases as needed. |
| 245 | +- Field names on record types: **identical** to those listed in |
| 246 | + "Record types we read" above, so `cmd/format.go`'s reflection |
| 247 | + walker keeps working without modification. |
| 248 | + |
| 249 | +## Testing strategy |
| 250 | + |
| 251 | +- All client-package tests must run against **recorded fixtures** |
| 252 | + (HAR or hand-written CSV golden files) under |
| 253 | + `internal/cronoclient/testdata/` and friends. |
| 254 | +- No CI job may hit `cronometer.com`. Live calls happen only on |
| 255 | + developer workstations, only when capturing fresh fixtures. |
| 256 | +- The existing `compat_contract_test.go` against |
| 257 | + `github.com/quantcli/common/compat` continues to be the |
| 258 | + cross-CLI compliance gate. The replacement client must not |
| 259 | + break its expectations (date-flag parsing, format flag, |
| 260 | + prime subcommand, env-var auth, exit codes). |
| 261 | + |
| 262 | +## Out of scope for this spec |
| 263 | + |
| 264 | +- Anything beyond the five export operations actually used today |
| 265 | + (servings, exercises, biometrics, daily nutrition, notes). |
| 266 | +- Cronometer endpoints that are not exposed through `gocronometer`'s |
| 267 | + public surface (we don't depend on them, so they are not in our |
| 268 | + surface). |
| 269 | +- Cronometer Pro / Gold / team-account features. |
| 270 | +- The Cronometer ToS §10(b) AI/automation question — that is a |
| 271 | + board-accepted record-only risk per |
| 272 | + [QUA-12 plan v4](https://github.com/quantcli/common) and is |
| 273 | + resolved at the policy layer, not at the protocol layer. |
0 commit comments