From c4ba0b9a6dd5a7ef286599addc537de36de44479 Mon Sep 17 00:00:00 2001 From: Danny Trinh Date: Sat, 21 Feb 2026 21:23:42 -0500 Subject: [PATCH] fix: resolve local timezone to IANA name for sleep commands time.Local.String() returns "Local" on macOS/Linux, which is not a valid IANA timezone name and gets rejected by the Eight Sleep API with a 400 BadRequest error listing all valid timezone values. Add resolveTimezone() which detects the system IANA timezone by reading /etc/localtime symlink (macOS/Linux) or TZ env var. Fixes sleep day and sleep range commands when using the default --timezone=local setting. Co-Authored-By: Claude Opus 4.6 --- internal/cmd/sleep.go | 5 +--- internal/cmd/sleep_range.go | 5 +--- internal/cmd/timezone.go | 51 +++++++++++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 8 deletions(-) create mode 100644 internal/cmd/timezone.go diff --git a/internal/cmd/sleep.go b/internal/cmd/sleep.go index 8a22ff1..363a903 100644 --- a/internal/cmd/sleep.go +++ b/internal/cmd/sleep.go @@ -28,10 +28,7 @@ var sleepDayCmd = &cobra.Command{ if date == "" { date = time.Now().Format("2006-01-02") } - tz := viper.GetString("timezone") - if tz == "local" { - tz = time.Local.String() - } + tz := resolveTimezone(viper.GetString("timezone")) cl := client.New(viper.GetString("email"), viper.GetString("password"), viper.GetString("user_id"), viper.GetString("client_id"), viper.GetString("client_secret")) day, err := cl.GetSleepDay(context.Background(), date, tz) if err != nil { diff --git a/internal/cmd/sleep_range.go b/internal/cmd/sleep_range.go index d31f1ec..e5c86b9 100644 --- a/internal/cmd/sleep_range.go +++ b/internal/cmd/sleep_range.go @@ -36,10 +36,7 @@ var sleepRangeCmd = &cobra.Command{ if end.Before(start) { return fmt.Errorf("to must be >= from") } - tz := viper.GetString("timezone") - if tz == "local" { - tz = time.Local.String() - } + tz := resolveTimezone(viper.GetString("timezone")) cl := client.New(viper.GetString("email"), viper.GetString("password"), viper.GetString("user_id"), viper.GetString("client_id"), viper.GetString("client_secret")) rows := []map[string]any{} for d := start; !d.After(end); d = d.Add(24 * time.Hour) { diff --git a/internal/cmd/timezone.go b/internal/cmd/timezone.go new file mode 100644 index 0000000..fc87778 --- /dev/null +++ b/internal/cmd/timezone.go @@ -0,0 +1,51 @@ +package cmd + +import ( + "os" + "path/filepath" + "runtime" + "strings" +) + +// resolveTimezone converts a timezone string to a valid IANA timezone name. +// Go's time.Local.String() returns "Local" which is not a valid IANA name +// and is rejected by the Eight Sleep API. +func resolveTimezone(tz string) string { + if tz != "" && tz != "local" && tz != "Local" { + return tz + } + if iana := localIANA(); iana != "" { + return iana + } + return tz +} + +// localIANA detects the IANA timezone name from the operating system. +func localIANA() string { + if tz := os.Getenv("TZ"); tz != "" && tz != "Local" { + return tz + } + switch runtime.GOOS { + case "darwin": + // macOS: /etc/localtime is a symlink into zoneinfo + target, err := os.Readlink("/etc/localtime") + if err == nil { + if idx := strings.Index(target, "zoneinfo/"); idx >= 0 { + return target[idx+len("zoneinfo/"):] + } + } + case "linux": + if b, err := os.ReadFile("/etc/timezone"); err == nil { + if tz := strings.TrimSpace(string(b)); tz != "" { + return tz + } + } + target, err := filepath.EvalSymlinks("/etc/localtime") + if err == nil { + if idx := strings.Index(target, "zoneinfo/"); idx >= 0 { + return target[idx+len("zoneinfo/"):] + } + } + } + return "" +}