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
24 changes: 19 additions & 5 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 Down Expand Up @@ -43,22 +44,35 @@ 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
}
}

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

// Try datetime: 2006-01-02 15:04:05
if t, err := time.ParseInLocation("2006-01-02 15:04:05", s, time.Local); err == nil {
return t.Unix(), nil
// Try 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
}
}

// Try unix timestamp
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)
}

// expandDays converts day shorthand (e.g. "7d", "30d") to hours for time.ParseDuration.
Expand Down
29 changes: 29 additions & 0 deletions internal/timeutil/parse_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,35 @@ func TestParse(t *testing.T) {
input: "+garbage",
wantErr: true,
},
// 21. RFC3339 with +08:00 offset — the format the SDK emits and the
// agent round-trips back as --since/--until (the bug this fixes).
{
name: "rfc3339 offset 2026-05-29T00:00:00+08:00",
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,
},
// 22. RFC3339 with "Z" (UTC)
{
name: "rfc3339 utc 2026-05-29T00:00:00Z",
input: "2026-05-29T00:00:00Z",
wantExact: time.Date(2026, 5, 29, 0, 0, 0, 0, time.UTC).Unix(),
exactMatch: true,
},
// 23. RFC3339Nano with fractional seconds + offset
{
name: "rfc3339nano 2026-05-29T00:00:00.5+08:00",
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,
},
// 24. "T"-separated datetime without timezone → local time
{
name: "datetime T-separated no tz 2026-05-29T14:00:00",
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