From b55d2e83c6a3a41aa3370ec1b4fbff3001fc730e Mon Sep 17 00:00:00 2001 From: DTTerastar Date: Sat, 25 Apr 2026 17:32:59 -0400 Subject: [PATCH] Add fitdown-style markdown output; switch --json to --format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Default output for every subcommand (measurements, sleep, activity, workouts, intraday) is now a fitdown-inspired markdown format: date-heading blocks with concise stat lines, zero/empty fields suppressed. The shape is purpose-fit per record type — workouts read like fitdown's own examples, sleep shows in→out plus stage breakdown, intraday emits one timestamped line per sample. Replaces the per-command `--json` boolean with `--format`, which accepts `markdown` (default), `json`, or `csv`. CSV writers are preserved verbatim for backwards compat. Bad values produce a clear error. Number rendering tightened in markdown: HR/calories rounded to integer, sleep HR avg to 1 decimal, body measurements to 3 decimals (stripping float-precision noise like 107.35000000000001 → 107.35), and activity soft/moderate/intense rendered as `fmtDur(seconds)` instead of raw seconds. README updated with new flag syntax, sample markdown output, and an `intraday` usage section. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 56 +++++++++---- cmd/activity.go | 47 +++++++++-- cmd/intraday.go | 63 ++++++++++++-- cmd/measurements.go | 68 +++++++++++++-- cmd/shared.go | 47 +++++++++++ cmd/sleep.go | 198 ++++++++++++++++++++++++++++++-------------- cmd/workouts.go | 77 +++++++++++++++-- 7 files changed, 454 insertions(+), 102 deletions(-) diff --git a/README.md b/README.md index fd6c171..27577dd 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # withings-export-cli -Export health data from [Withings](https://www.withings.com) scales, watches, and trackers. A command-line tool to back up measurements, sleep summaries, activity, and workouts — all from your terminal, with CSV or JSON output. +Export health data from [Withings](https://www.withings.com) scales, watches, and trackers. A command-line tool to back up measurements, sleep summaries, activity, and workouts — all from your terminal, with [fitdown](https://github.com/datavis-tech/fitdown)-style markdown by default and JSON or CSV on demand. [![Latest Release](https://img.shields.io/github/v/release/quantcli/withings-export-cli)](https://github.com/quantcli/withings-export-cli/releases/latest) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) @@ -14,7 +14,7 @@ Export health data from [Withings](https://www.withings.com) scales, watches, an - **Activity** — daily steps, distance, elevation, calories, HR zones - **Workouts** — runs, walks, bikes, swims, etc. with duration, HR, distance, and more - **Date filtering** — relative (`30d`, `4w`, `6m`, `1y`) or absolute (`2026-01-01`) -- **CSV by default, JSON with `--json`** — pipe into your tool of choice +- **Fitdown-style markdown by default**, plus `--format json` and `--format csv` — pipe into your tool of choice - **Multi-platform** — pre-built binaries for macOS (Intel + Apple Silicon), Linux, and Windows ## Quick Start @@ -107,11 +107,12 @@ withings-export auth refresh # Force refresh and print status ### Measurements (scales, BP monitors, ECG) ```sh -withings-export measurements # last 30 days, CSV to stdout -withings-export measurements --since 1y # last year +withings-export measurements # last 30 days, markdown +withings-export measurements --since 1y # last year withings-export measurements --since 2026-01-01 -withings-export measurements --types 1,6,76 # only weight, body fat %, muscle mass -withings-export measurements --json # JSON instead of CSV +withings-export measurements --types 1,6,76 # only weight, body fat %, muscle mass +withings-export measurements --format json # JSON for scripting +withings-export measurements --format csv # CSV for spreadsheets ``` Common measure type codes: `1`=weight (kg), `6`=body fat %, `8`=fat mass (kg), `9`=diastolic BP, `10`=systolic BP, `11`=heart pulse, `54`=SpO₂ %, `76`=muscle mass, `77`=hydration, `88`=bone mass. @@ -119,9 +120,11 @@ Common measure type codes: `1`=weight (kg), `6`=body fat %, `8`=fat mass (kg), ` ### Sleep ```sh -withings-export sleep # last 30 nights, CSV +withings-export sleep # last 30 nights, markdown withings-export sleep --since 6m # last 6 months -withings-export sleep --json +withings-export sleep --derive # polyfill nights with no Withings summary + # using intraday HR samples (Apple Watch via HealthKit) +withings-export sleep --format json ``` Includes total sleep time, stages (light/deep/REM), sleep score, heart rate, respiratory rate, snoring episodes, and apnea-hypopnea index (if supported by your device). @@ -129,9 +132,9 @@ Includes total sleep time, stages (light/deep/REM), sleep score, heart rate, res ### Activity ```sh -withings-export activity # last 30 days, CSV +withings-export activity # last 30 days, markdown withings-export activity --since 1y -withings-export activity --json +withings-export activity --format json ``` Daily steps, distance, elevation, calories, time in HR zones. @@ -139,22 +142,43 @@ Daily steps, distance, elevation, calories, time in HR zones. ### Workouts ```sh -withings-export workouts # last 90 days, CSV +withings-export workouts # last 90 days, markdown withings-export workouts --since 6m -withings-export workouts --json +withings-export workouts --format json ``` Per-workout category (run/walk/bike/etc.), duration, calories, HR, distance, elevation. -## Output Format +### Intraday -**CSV (default):** header row + one row per data point. Suitable for spreadsheets, Grafana, pandas, etc. +```sh +withings-export intraday # last 24h, minute-level samples +withings-export intraday --since 7d +withings-export intraday --format csv +``` + +Per-minute heart rate, HRV (rmssd, sdnn1), SpO₂, steps, and distance — typically populated by an Apple Watch via the HealthKit bridge or by a native Withings tracker. + +## Output Formats + +**Markdown (default):** [fitdown](https://github.com/datavis-tech/fitdown)-style plain-text blocks with a date heading per record. Human-readable and easy to skim: + +``` +Workout 2026-04-23 + +lift_weights +08:58 → 10:12 (73 min) +482 cal +HR avg 76, 50-127 +``` + +**JSON (`--format json`):** pretty-printed JSON array. Good for `jq` or custom scripts. -**JSON (`--json`):** pretty-printed JSON array. Good for `jq` or custom scripts. +**CSV (`--format csv`):** header row + one row per data point. Suitable for spreadsheets, Grafana, pandas, etc. Example — average weight over the last year: ```sh -withings-export measurements --since 1y --types 1 --json \ +withings-export measurements --since 1y --types 1 --format json \ | jq '[.[] | .value] | add / length' ``` diff --git a/cmd/activity.go b/cmd/activity.go index ea91725..69ec599 100644 --- a/cmd/activity.go +++ b/cmd/activity.go @@ -2,6 +2,7 @@ package cmd import ( "encoding/csv" + "fmt" "net/url" "os" "sort" @@ -40,8 +41,8 @@ type activityResponse struct { } var ( - activityJSONFlag bool - activitySinceFlag string + activityFormatFlag string + activitySinceFlag string ) var activityCmd = &cobra.Command{ @@ -78,10 +79,18 @@ var activityCmd = &cobra.Command{ sort.Slice(all, func(i, j int) bool { return all[i].Date < all[j].Date }) - if activityJSONFlag { + format, err := validateFormat(activityFormatFlag) + if err != nil { + return err + } + switch format { + case "json": return printJSON(all) + case "csv": + return writeActivityCSV(all) + default: + return writeActivityMarkdown(all) } - return writeActivityCSV(all) }, } @@ -126,9 +135,35 @@ func writeActivityCSV(days []activityDay) error { return nil } +// writeActivityMarkdown emits one fitdown-style block per day. Empty/zero +// fields are dropped to keep output tight. +func writeActivityMarkdown(days []activityDay) error { + for _, d := range days { + fmt.Fprintf(os.Stdout, "Activity %s\n\n", d.Date) + if d.Steps > 0 || d.Distance > 0 { + fmt.Fprintf(os.Stdout, "%d steps, %.2f km\n", d.Steps, d.Distance/1000) + } + if d.Calories > 0 || d.TotalCalories > 0 { + fmt.Fprintf(os.Stdout, "%s cal active, %s total\n", fmtRound(d.Calories), fmtRound(d.TotalCalories)) + } + if d.HRAvg > 0 { + fmt.Fprintf(os.Stdout, "HR avg %s, %s-%s\n", fmtRound(d.HRAvg), fmtRound(d.HRMin), fmtRound(d.HRMax)) + } + if d.Active > 0 { + fmt.Fprintf(os.Stdout, "Active %s — %s soft, %s moderate, %s intense\n", + fmtDur(d.Active), fmtDur(d.Soft), fmtDur(d.Moderate), fmtDur(d.Intense)) + } + if d.Elevation > 0 { + fmt.Fprintf(os.Stdout, "%s m elevation\n", fmtRound(d.Elevation)) + } + fmt.Fprintln(os.Stdout) + } + return nil +} + func init() { activityCmd.Flags().StringVar(&activitySinceFlag, "since", "", "Filter on or after date (e.g. 2026-01-01, 30d, 4w, 6m, 1y; default 30d)") - activityCmd.Flags().BoolVar(&activityJSONFlag, "json", false, - "Output as JSON instead of CSV") + activityCmd.Flags().StringVar(&activityFormatFlag, "format", "markdown", + "Output format: markdown (default, fitdown-style), json, or csv") } diff --git a/cmd/intraday.go b/cmd/intraday.go index fc8fd46..02d1d00 100644 --- a/cmd/intraday.go +++ b/cmd/intraday.go @@ -2,6 +2,7 @@ package cmd import ( "encoding/csv" + "fmt" "net/url" "os" "sort" @@ -51,8 +52,8 @@ type intradayResponse struct { } var ( - intradayJSONFlag bool - intradaySinceFlag string + intradayFormatFlag string + intradaySinceFlag string ) var intradayCmd = &cobra.Command{ @@ -118,10 +119,18 @@ Default window is the last 24h — intraday is dense; wider ranges are slow.`, sort.Slice(all, func(i, j int) bool { return all[i].Timestamp < all[j].Timestamp }) - if intradayJSONFlag { + format, err := validateFormat(intradayFormatFlag) + if err != nil { + return err + } + switch format { + case "json": return printJSON(all) + case "csv": + return writeIntradayCSV(all) + default: + return writeIntradayMarkdown(all) } - return writeIntradayCSV(all) }, } @@ -161,9 +170,51 @@ func writeIntradayCSV(samples []intradaySample) error { return nil } +// writeIntradayMarkdown emits one fitdown-style "Intraday DATE" block per +// local day, with one line per sample carrying only the fields that have +// values. Suppressing zero fields keeps the firehose readable. +func writeIntradayMarkdown(samples []intradaySample) error { + var lastDate string + for _, s := range samples { + ts := time.Unix(s.Timestamp, 0).Local() + date := ts.Format("2006-01-02") + if date != lastDate { + if lastDate != "" { + fmt.Fprintln(os.Stdout) + } + fmt.Fprintf(os.Stdout, "Intraday %s\n\n", date) + lastDate = date + } + fmt.Fprint(os.Stdout, ts.Format("15:04:05")) + if s.HeartRate > 0 { + fmt.Fprintf(os.Stdout, " HR %d", s.HeartRate) + } + if s.RMSSD > 0 { + fmt.Fprintf(os.Stdout, " rmssd %s", fmtNum(s.RMSSD)) + } + if s.SDNN1 > 0 { + fmt.Fprintf(os.Stdout, " sdnn1 %s", fmtNum(s.SDNN1)) + } + if s.SpO2 > 0 { + fmt.Fprintf(os.Stdout, " spo2 %s", fmtNum(s.SpO2)) + } + if s.Steps > 0 { + fmt.Fprintf(os.Stdout, " %d steps", s.Steps) + } + if s.Distance > 0 { + fmt.Fprintf(os.Stdout, " %sm", fmtNum(s.Distance)) + } + if s.Calories > 0 { + fmt.Fprintf(os.Stdout, " %s cal", fmtNum(s.Calories)) + } + fmt.Fprintln(os.Stdout) + } + return nil +} + func init() { intradayCmd.Flags().StringVar(&intradaySinceFlag, "since", "", "Filter on or after date (e.g. 2026-04-15, 1d, 4w, 6m; default 1d)") - intradayCmd.Flags().BoolVar(&intradayJSONFlag, "json", false, - "Output as JSON instead of CSV") + intradayCmd.Flags().StringVar(&intradayFormatFlag, "format", "markdown", + "Output format: markdown (default, fitdown-style), json, or csv") } diff --git a/cmd/measurements.go b/cmd/measurements.go index 2f8ced7..f40b34f 100644 --- a/cmd/measurements.go +++ b/cmd/measurements.go @@ -70,9 +70,9 @@ type measurementRow struct { } var ( - measurementsJSONFlag bool - measurementsSinceFlag string - measurementsTypesFlag string + measurementsFormatFlag string + measurementsSinceFlag string + measurementsTypesFlag string ) var measurementsCmd = &cobra.Command{ @@ -126,10 +126,18 @@ var measurementsCmd = &cobra.Command{ sort.Slice(rows, func(i, j int) bool { return rows[i].Date.Before(rows[j].Date) }) - if measurementsJSONFlag { + format, err := validateFormat(measurementsFormatFlag) + if err != nil { + return err + } + switch format { + case "json": return printJSON(rows) + case "csv": + return writeMeasurementsCSV(rows) + default: + return writeMeasurementsMarkdown(rows) } - return writeMeasurementsCSV(rows) }, } @@ -154,11 +162,57 @@ func writeMeasurementsCSV(rows []measurementRow) error { return nil } +// fmtMeasurement renders a measurement value with up to 3 decimal places, +// stripping float-precision noise (e.g. 107.35000000000001 → 107.35). +func fmtMeasurement(v float64) string { + s := strconv.FormatFloat(v, 'f', 3, 64) + // trim trailing zeros and trailing dot + for len(s) > 0 && s[len(s)-1] == '0' { + s = s[:len(s)-1] + } + if len(s) > 0 && s[len(s)-1] == '.' { + s = s[:len(s)-1] + } + return s +} + +// writeMeasurementsMarkdown emits one fitdown-style block per measurement +// group (a single weigh-in event), with one line per measure type. +func writeMeasurementsMarkdown(rows []measurementRow) error { + type group struct { + date time.Time + rows []measurementRow + } + groups := map[int64]*group{} + var order []int64 + for _, r := range rows { + g, ok := groups[r.GrpID] + if !ok { + g = &group{date: r.Date} + groups[r.GrpID] = g + order = append(order, r.GrpID) + } + g.rows = append(g.rows, r) + } + sort.Slice(order, func(i, j int) bool { + return groups[order[i]].date.Before(groups[order[j]].date) + }) + for _, id := range order { + g := groups[id] + fmt.Fprintf(os.Stdout, "Measurements %s\n\n", g.date.Format("2006-01-02 15:04")) + for _, r := range g.rows { + fmt.Fprintf(os.Stdout, "%s %s\n", r.Type, fmtMeasurement(r.Value)) + } + fmt.Fprintln(os.Stdout) + } + return nil +} + func init() { measurementsCmd.Flags().StringVar(&measurementsSinceFlag, "since", "", "Filter on or after date (e.g. 2026-01-01, 30d, 4w, 6m, 1y; default 30d)") - measurementsCmd.Flags().BoolVar(&measurementsJSONFlag, "json", false, - "Output as JSON instead of CSV") + measurementsCmd.Flags().StringVar(&measurementsFormatFlag, "format", "markdown", + "Output format: markdown (default, fitdown-style), json, or csv") measurementsCmd.Flags().StringVar(&measurementsTypesFlag, "types", "", "Comma-separated measure type codes to include (e.g. 1,6,76); default is all") } diff --git a/cmd/shared.go b/cmd/shared.go index 3b00a63..6aedab3 100644 --- a/cmd/shared.go +++ b/cmd/shared.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "os" + "strconv" "time" ) @@ -58,3 +59,49 @@ func printJSON(v any) error { } return nil } + +// validateFormat checks that --format is one of the supported choices and +// normalizes it to a canonical lowercase value. +func validateFormat(format string) (string, error) { + switch format { + case "", "markdown", "md": + return "markdown", nil + case "json": + return "json", nil + case "csv": + return "csv", nil + default: + return "", fmt.Errorf("unknown --format %q (use markdown, json, or csv)", format) + } +} + +// fmtDur renders seconds as a fitdown-friendly duration: "8h04", "47 min", +// or "0 min" for zero. Negative values render as empty. +func fmtDur(seconds int) string { + if seconds < 0 { + return "" + } + h := seconds / 3600 + m := (seconds % 3600) / 60 + if h > 0 { + return fmt.Sprintf("%dh%02d", h, m) + } + return fmt.Sprintf("%d min", m) +} + +// fmtNum trims trailing zeros from a float for human-readable output. +func fmtNum(v float64) string { + return strconv.FormatFloat(v, 'f', -1, 64) +} + +// fmtRound rounds a float to the nearest integer. Used in markdown output for +// values where the API returns trailing-precision noise (HR, calories). +func fmtRound(v float64) string { + return fmt.Sprintf("%.0f", v) +} + +// fmtFloat1 renders a float with one decimal place — useful for HR averages +// where ~10ths of a bpm carry a little signal but full precision is noise. +func fmtFloat1(v float64) string { + return fmt.Sprintf("%.1f", v) +} diff --git a/cmd/sleep.go b/cmd/sleep.go index 66c6f17..d5ae7d8 100644 --- a/cmd/sleep.go +++ b/cmd/sleep.go @@ -3,6 +3,7 @@ package cmd import ( "encoding/csv" "encoding/json" + "fmt" "net/url" "os" "sort" @@ -33,7 +34,7 @@ type sleepResponse struct { } var ( - sleepJSONFlag bool + sleepFormatFlag string sleepSinceFlag string sleepDeriveFlag bool ) @@ -105,13 +106,90 @@ var sleepCmd = &cobra.Command{ sort.Slice(all, func(i, j int) bool { return all[i].StartDate < all[j].StartDate }) - if sleepJSONFlag { + format, err := validateFormat(sleepFormatFlag) + if err != nil { + return err + } + switch format { + case "json": return printJSON(all) + case "csv": + return writeSleepCSV(all) + default: + return writeSleepMarkdown(all) } - return writeSleepCSV(all) }, } +func writeSleepCSV(series []sleepSeries) error { + w := csv.NewWriter(os.Stdout) + defer w.Flush() + header := []string{ + "date", "start", "end", "timezone", + "total_sleep_min", "light_min", "deep_min", "rem_min", + "time_to_sleep_sec", "time_to_wakeup_sec", "wakeup_count", + "sleep_score", "hr_avg", "hr_min", "hr_max", + "rr_avg", "rr_min", "rr_max", "snore_episodes", "apnea_hypopnea_index", + "source", + } + if err := w.Write(header); err != nil { + return err + } + for _, s := range series { + var d struct { + Wake int `json:"wakeupduration"` + Light int `json:"lightsleepduration"` + Deep int `json:"deepsleepduration"` + REM int `json:"remsleepduration"` + ToSleep int `json:"durationtosleep"` + ToWakeup int `json:"durationtowakeup"` + WakeupCount int `json:"wakeupcount"` + SleepScore int `json:"sleep_score"` + HRAvg float64 `json:"hr_average"` + HRMin float64 `json:"hr_min"` + HRMax float64 `json:"hr_max"` + RRAvg float64 `json:"rr_average"` + RRMin float64 `json:"rr_min"` + RRMax float64 `json:"rr_max"` + SnoreEpisodes int `json:"snoringepisodecount"` + ApneaHypopnea float64 `json:"apnea_hypopnea_index"` + } + _ = json.Unmarshal(s.Data, &d) + + start := time.Unix(s.StartDate, 0).Local() + end := time.Unix(s.EndDate, 0).Local() + totalSleepMin := (d.Light + d.Deep + d.REM) / 60 + + row := []string{ + s.Date, + start.Format(time.RFC3339), + end.Format(time.RFC3339), + s.Timezone, + strconv.Itoa(totalSleepMin), + strconv.Itoa(d.Light / 60), + strconv.Itoa(d.Deep / 60), + strconv.Itoa(d.REM / 60), + strconv.Itoa(d.ToSleep), + strconv.Itoa(d.ToWakeup), + strconv.Itoa(d.WakeupCount), + strconv.Itoa(d.SleepScore), + strconv.FormatFloat(d.HRAvg, 'f', -1, 64), + strconv.FormatFloat(d.HRMin, 'f', -1, 64), + strconv.FormatFloat(d.HRMax, 'f', -1, 64), + strconv.FormatFloat(d.RRAvg, 'f', -1, 64), + strconv.FormatFloat(d.RRMin, 'f', -1, 64), + strconv.FormatFloat(d.RRMax, 'f', -1, 64), + strconv.Itoa(d.SnoreEpisodes), + strconv.FormatFloat(d.ApneaHypopnea, 'f', -1, 64), + s.Source, + } + if err := w.Write(row); err != nil { + return err + } + } + return nil +} + // deriveSleep polyfills a sleep start/end for a night that has no getsummary // record, by finding the longest contiguous "quiet" run of intraday samples. // @@ -266,72 +344,70 @@ func deriveSleep(c *client.Client, date time.Time) (*sleepSeries, error) { }, nil } -func writeSleepCSV(series []sleepSeries) error { - w := csv.NewWriter(os.Stdout) - defer w.Flush() - header := []string{ - "date", "start", "end", "timezone", - "total_sleep_min", "light_min", "deep_min", "rem_min", - "time_to_sleep_sec", "time_to_wakeup_sec", "wakeup_count", - "sleep_score", "hr_avg", "hr_min", "hr_max", - "rr_avg", "rr_min", "rr_max", "snore_episodes", "apnea_hypopnea_index", - "source", - } - if err := w.Write(header); err != nil { - return err - } - +// writeSleepMarkdown emits one fitdown-style block per night. Stage breakdown, +// score, RR, and snore lines only render when populated. Derived rows tag the +// heading so polyfilled nights are visually distinct from real summaries. +func writeSleepMarkdown(series []sleepSeries) error { for _, s := range series { var d struct { - Wake int `json:"wakeupduration"` - Light int `json:"lightsleepduration"` - Deep int `json:"deepsleepduration"` - REM int `json:"remsleepduration"` - ToSleep int `json:"durationtosleep"` - ToWakeup int `json:"durationtowakeup"` - WakeupCount int `json:"wakeupcount"` - SleepScore int `json:"sleep_score"` - HRAvg float64 `json:"hr_average"` - HRMin float64 `json:"hr_min"` - HRMax float64 `json:"hr_max"` - RRAvg float64 `json:"rr_average"` - RRMin float64 `json:"rr_min"` - RRMax float64 `json:"rr_max"` - SnoreEpisodes int `json:"snoringepisodecount"` - ApneaHypopnea float64 `json:"apnea_hypopnea_index"` + Wake int `json:"wakeupduration"` + Light int `json:"lightsleepduration"` + Deep int `json:"deepsleepduration"` + REM int `json:"remsleepduration"` + ToSleep int `json:"durationtosleep"` + ToWakeup int `json:"durationtowakeup"` + WakeupCount int `json:"wakeupcount"` + SleepScore int `json:"sleep_score"` + HRAvg float64 `json:"hr_average"` + HRMin float64 `json:"hr_min"` + HRMax float64 `json:"hr_max"` + RRAvg float64 `json:"rr_average"` + RRMin float64 `json:"rr_min"` + RRMax float64 `json:"rr_max"` + SnoreEpisodes int `json:"snoringepisodecount"` + ApneaHypopnea float64 `json:"apnea_hypopnea_index"` } _ = json.Unmarshal(s.Data, &d) start := time.Unix(s.StartDate, 0).Local() end := time.Unix(s.EndDate, 0).Local() - totalSleepMin := (d.Light + d.Deep + d.REM) / 60 + total := d.Light + d.Deep + d.REM - row := []string{ - s.Date, - start.Format(time.RFC3339), - end.Format(time.RFC3339), - s.Timezone, - strconv.Itoa(totalSleepMin), - strconv.Itoa(d.Light / 60), - strconv.Itoa(d.Deep / 60), - strconv.Itoa(d.REM / 60), - strconv.Itoa(d.ToSleep), - strconv.Itoa(d.ToWakeup), - strconv.Itoa(d.WakeupCount), - strconv.Itoa(d.SleepScore), - strconv.FormatFloat(d.HRAvg, 'f', -1, 64), - strconv.FormatFloat(d.HRMin, 'f', -1, 64), - strconv.FormatFloat(d.HRMax, 'f', -1, 64), - strconv.FormatFloat(d.RRAvg, 'f', -1, 64), - strconv.FormatFloat(d.RRMin, 'f', -1, 64), - strconv.FormatFloat(d.RRMax, 'f', -1, 64), - strconv.Itoa(d.SnoreEpisodes), - strconv.FormatFloat(d.ApneaHypopnea, 'f', -1, 64), - s.Source, + heading := fmt.Sprintf("Sleep %s", s.Date) + if s.Source == "derived" { + heading += " (derived)" } - if err := w.Write(row); err != nil { - return err + fmt.Fprintf(os.Stdout, "%s\n\n", heading) + fmt.Fprintf(os.Stdout, "%s → %s (%s)\n", + start.Format("15:04"), end.Format("15:04"), fmtDur(total)) + + if d.HRAvg > 0 { + if d.HRMin > 0 || d.HRMax > 0 { + fmt.Fprintf(os.Stdout, "HR avg %s, %s-%s\n", fmtFloat1(d.HRAvg), fmtRound(d.HRMin), fmtRound(d.HRMax)) + } else { + fmt.Fprintf(os.Stdout, "HR avg %s\n", fmtFloat1(d.HRAvg)) + } + } + if d.Deep > 0 || d.REM > 0 { + fmt.Fprintf(os.Stdout, "Light %s, deep %s, REM %s\n", + fmtDur(d.Light), fmtDur(d.Deep), fmtDur(d.REM)) + } + if d.SleepScore > 0 { + fmt.Fprintf(os.Stdout, "Score %d\n", d.SleepScore) + } + if d.RRAvg > 0 { + fmt.Fprintf(os.Stdout, "RR avg %s, %s-%s\n", fmtFloat1(d.RRAvg), fmtRound(d.RRMin), fmtRound(d.RRMax)) + } + if d.WakeupCount > 0 { + fmt.Fprintf(os.Stdout, "%d wakeups\n", d.WakeupCount) + } + if d.ApneaHypopnea > 0 { + fmt.Fprintf(os.Stdout, "AHI %s\n", fmtNum(d.ApneaHypopnea)) + } + if d.SnoreEpisodes > 0 { + fmt.Fprintf(os.Stdout, "Snoring: %d episodes\n", d.SnoreEpisodes) } + fmt.Fprintln(os.Stdout) } return nil } @@ -339,8 +415,8 @@ func writeSleepCSV(series []sleepSeries) error { func init() { sleepCmd.Flags().StringVar(&sleepSinceFlag, "since", "", "Filter on or after date (e.g. 2026-01-01, 30d, 4w, 6m, 1y; default 30d)") - sleepCmd.Flags().BoolVar(&sleepJSONFlag, "json", false, - "Output as JSON instead of CSV") + sleepCmd.Flags().StringVar(&sleepFormatFlag, "format", "markdown", + "Output format: markdown (default, fitdown-style), json, or csv") sleepCmd.Flags().BoolVar(&sleepDeriveFlag, "derive", false, "For nights with no Withings sleep summary, polyfill start/end from intraday heart-rate samples") } diff --git a/cmd/workouts.go b/cmd/workouts.go index d9bded3..2eaedfb 100644 --- a/cmd/workouts.go +++ b/cmd/workouts.go @@ -3,6 +3,7 @@ package cmd import ( "encoding/csv" "encoding/json" + "fmt" "net/url" "os" "sort" @@ -85,8 +86,8 @@ type workoutsResponse struct { } var ( - workoutsJSONFlag bool - workoutsSinceFlag string + workoutsFormatFlag string + workoutsSinceFlag string ) var workoutsCmd = &cobra.Command{ @@ -125,10 +126,18 @@ var workoutsCmd = &cobra.Command{ sort.Slice(all, func(i, j int) bool { return all[i].StartDate < all[j].StartDate }) - if workoutsJSONFlag { + format, err := validateFormat(workoutsFormatFlag) + if err != nil { + return err + } + switch format { + case "json": return printJSON(all) + case "csv": + return writeWorkoutsCSV(all) + default: + return writeWorkoutsMarkdown(all) } - return writeWorkoutsCSV(all) }, } @@ -199,9 +208,65 @@ func writeWorkoutsCSV(series []workoutSeries) error { return nil } +// writeWorkoutsMarkdown emits one fitdown-style block per workout: a +// "Workout DATE" heading, the activity name, then concise stat lines. The +// shape mirrors fitdown's own examples (Workout … / category / details). +func writeWorkoutsMarkdown(series []workoutSeries) error { + for _, s := range series { + var d struct { + Calories float64 `json:"calories"` + EffDuration int `json:"effduration"` + Distance float64 `json:"distance"` + Elevation float64 `json:"elevation"` + Steps int `json:"steps"` + HRAvg float64 `json:"hr_average"` + HRMin float64 `json:"hr_min"` + HRMax float64 `json:"hr_max"` + HRZone0 int `json:"hr_zone_0"` + HRZone1 int `json:"hr_zone_1"` + HRZone2 int `json:"hr_zone_2"` + HRZone3 int `json:"hr_zone_3"` + } + _ = json.Unmarshal(s.Data, &d) + + start := time.Unix(s.StartDate, 0).Local() + end := time.Unix(s.EndDate, 0).Local() + category := workoutCategoryNames[s.Category] + if category == "" { + category = "unknown" + } + + fmt.Fprintf(os.Stdout, "Workout %s\n\n", s.Date) + fmt.Fprintf(os.Stdout, "%s\n", category) + fmt.Fprintf(os.Stdout, "%s → %s (%d min)\n", + start.Format("15:04"), end.Format("15:04"), + int(end.Sub(start).Minutes())) + if d.Calories > 0 { + fmt.Fprintf(os.Stdout, "%s cal\n", fmtRound(d.Calories)) + } + if d.HRAvg > 0 { + fmt.Fprintf(os.Stdout, "HR avg %s, %s-%s\n", fmtRound(d.HRAvg), fmtRound(d.HRMin), fmtRound(d.HRMax)) + } + if d.Steps > 0 { + if d.Distance > 0 { + fmt.Fprintf(os.Stdout, "%d steps, %s m\n", d.Steps, fmtRound(d.Distance)) + } else { + fmt.Fprintf(os.Stdout, "%d steps\n", d.Steps) + } + } else if d.Distance > 0 { + fmt.Fprintf(os.Stdout, "%s m\n", fmtRound(d.Distance)) + } + if d.Elevation > 0 { + fmt.Fprintf(os.Stdout, "%s m elevation\n", fmtRound(d.Elevation)) + } + fmt.Fprintln(os.Stdout) + } + return nil +} + func init() { workoutsCmd.Flags().StringVar(&workoutsSinceFlag, "since", "", "Filter on or after date (e.g. 2026-01-01, 30d, 4w, 6m, 1y; default 90d)") - workoutsCmd.Flags().BoolVar(&workoutsJSONFlag, "json", false, - "Output as JSON instead of CSV") + workoutsCmd.Flags().StringVar(&workoutsFormatFlag, "format", "markdown", + "Output format: markdown (default, fitdown-style), json, or csv") }