Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 19 additions & 4 deletions internal/timeutil/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ import (
// Interpreted as "now plus duration"
// - Day shorthand: "7d", "30d" — converted to hours automatically
// - Date: "2026-04-01" (parsed as local midnight)
// - Datetime: "2026-04-01 10:00:00" (parsed as local time)
// - Datetime: "2026-04-01 10:00:00" or "2026-04-01T10:00:00" (parsed as local time)
// - RFC3339 with timezone: "2026-04-01T10:00:00+08:00" / "...Z" (the format the SDK emits)
// - Unix timestamp: "1712000000" (passed through)
func Parse(s string) (int64, error) {
s = strings.TrimSpace(s)
Expand All @@ -41,19 +42,33 @@ func Parse(s string) (int64, error) {
return time.Now().Add(-d).Unix(), nil
}

// Try RFC3339 / ISO8601 with an explicit timezone first. This is the
// format the flashduty SDK renders timestamps in (e.g.
// "2026-05-29T00:00:00+08:00"), so the agent naturally round-trips those
// values straight back as since/until. time.Parse honors the embedded
// offset ("Z" or "+08:00").
for _, layout := range []string{time.RFC3339Nano, time.RFC3339} {
if t, err := time.Parse(layout, s); err == nil {
return t.Unix(), nil
}
}

if t, err := time.ParseInLocation("2006-01-02", s, time.Local); err == nil {
return t.Unix(), nil
}

if t, err := time.ParseInLocation("2006-01-02 15:04:05", s, time.Local); err == nil {
return t.Unix(), nil
// Datetime without timezone, space- or "T"-separated → local time.
for _, layout := range []string{"2006-01-02 15:04:05", "2006-01-02T15:04:05"} {
if t, err := time.ParseInLocation(layout, s, time.Local); err == nil {
return t.Unix(), nil
}
}

if ts, err := strconv.ParseInt(s, 10, 64); err == nil && ts > 1000000000 {
return ts, nil
}

return 0, fmt.Errorf("unable to parse time %q: expected duration (24h), date (2006-01-02), datetime (2006-01-02 15:04:05), or unix timestamp", s)
return 0, fmt.Errorf("unable to parse time %q: expected duration (24h), RFC3339 (2006-01-02T15:04:05Z07:00), date (2006-01-02), datetime (2006-01-02 15:04:05), or unix timestamp", s)
}

// ParseAny accepts the same string formats as Parse, plus raw numeric unix
Expand Down
6 changes: 6 additions & 0 deletions internal/timeutil/parse_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ func TestParse(t *testing.T) {
{name: "future +7d", input: "+7d", wantApprox: now + int64(7*24*time.Hour/time.Second), approxMatch: true, tolerance: 2},
{name: "past 7d", input: "7d", wantApprox: now - int64(7*24*time.Hour/time.Second), approxMatch: true, tolerance: 2},
{name: "future garbage", input: "+garbage", wantErr: true},
// RFC3339 round-trip — the format the SDK emits and the agent feeds
// straight back as since/until (the bug this fixes).
{name: "rfc3339 offset", input: "2026-05-29T00:00:00+08:00", wantExact: time.Date(2026, 5, 29, 0, 0, 0, 0, time.FixedZone("", 8*3600)).Unix(), exactMatch: true},
{name: "rfc3339 utc Z", input: "2026-05-29T00:00:00Z", wantExact: time.Date(2026, 5, 29, 0, 0, 0, 0, time.UTC).Unix(), exactMatch: true},
{name: "rfc3339nano fractional", input: "2026-05-29T00:00:00.5+08:00", wantExact: time.Date(2026, 5, 29, 0, 0, 0, 0, time.FixedZone("", 8*3600)).Unix(), exactMatch: true},
{name: "datetime T no tz local", input: "2026-05-29T14:00:00", wantExact: time.Date(2026, 5, 29, 14, 0, 0, 0, time.Local).Unix(), exactMatch: true},
}

for _, tc := range tests {
Expand Down
Loading