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.
[](https://github.com/quantcli/withings-export-cli/releases/latest)
[](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")
}