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 "" +}