diff --git a/backend/gtime/gtime.go b/backend/gtime/gtime.go new file mode 100644 index 000000000..22e6eefb8 --- /dev/null +++ b/backend/gtime/gtime.go @@ -0,0 +1,87 @@ +package gtime + +import ( + "fmt" + "regexp" + "strconv" + "time" +) + +var dateUnitPattern = regexp.MustCompile(`^(\d+)([dwMy])$`) + +// ParseInterval parses an interval with support for all units that Grafana uses. +// An interval is relative to the current wall time. +func ParseInterval(inp string) (time.Duration, error) { + dur, period, err := parse(inp) + if err != nil { + return 0, err + } + if period == "" { + return dur, nil + } + + num := int(dur) + + // Use UTC to ensure that the interval is deterministic, and daylight saving + // doesn't cause surprises + now := time.Now().UTC() + switch period { + case "d": + return now.AddDate(0, 0, num).Sub(now), nil + case "w": + return now.AddDate(0, 0, num*7).Sub(now), nil + case "M": + return now.AddDate(0, num, 0).Sub(now), nil + case "y": + return now.AddDate(num, 0, 0).Sub(now), nil + } + + return 0, fmt.Errorf("invalid interval %q", inp) +} + +// ParseDuration parses a duration with support for all units that Grafana uses. +// Durations are independent of wall time. +func ParseDuration(inp string) (time.Duration, error) { + dur, period, err := parse(inp) + if err != nil { + return 0, err + } + if period == "" { + return dur, nil + } + + // The average number of days in a year, using the Julian calendar + const daysInAYear = 365.25 + const day = 24 * time.Hour + const week = 7 * day + const year = time.Duration(float64(day) * daysInAYear) + const month = time.Duration(float64(year) / 12) + + switch period { + case "d": + return dur * day, nil + case "w": + return dur * week, nil + case "M": + return dur * month, nil + case "y": + return dur * year, nil + } + + return 0, fmt.Errorf("invalid duration %q", inp) +} + +func parse(inp string) (time.Duration, string, error) { + result := dateUnitPattern.FindSubmatch([]byte(inp)) + if len(result) != 3 { + dur, err := time.ParseDuration(inp) + return dur, "", err + } + + num, err := strconv.Atoi(string(result[1])) + if err != nil { + return 0, "", err + } + + return time.Duration(num), string(result[2]), nil +} diff --git a/backend/gtime/gtime_test.go b/backend/gtime/gtime_test.go new file mode 100644 index 000000000..93111e499 --- /dev/null +++ b/backend/gtime/gtime_test.go @@ -0,0 +1,102 @@ +package gtime + +import ( + "fmt" + "regexp" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestParseInterval(t *testing.T) { + daysInMonth, daysInYear := calculateDays() + + tcs := []struct { + inp string + duration time.Duration + err *regexp.Regexp + }{ + {inp: "1d", duration: 24 * time.Hour}, + {inp: "1w", duration: 168 * time.Hour}, + {inp: "2w", duration: 2 * 168 * time.Hour}, + {inp: "1M", duration: time.Duration(daysInMonth * 24 * int(time.Hour))}, + {inp: "1y", duration: time.Duration(daysInYear * 24 * int(time.Hour))}, + {inp: "5y", duration: time.Duration(calculateDays5y() * 24 * int(time.Hour))}, + {inp: "invalid-duration", err: regexp.MustCompile(`^time: invalid duration "?invalid-duration"?$`)}, + } + for i, tc := range tcs { + t.Run(fmt.Sprintf("testcase %d", i), func(t *testing.T) { + res, err := ParseInterval(tc.inp) + if tc.err == nil { + require.NoError(t, err, "input %q", tc.inp) + require.Equal(t, tc.duration, res, "input %q", tc.inp) + } else { + require.Error(t, err, "input %q", tc.inp) + require.Regexp(t, tc.err, err.Error()) + } + }) + } +} + +func TestParseDuration(t *testing.T) { + tcs := []struct { + inp string + duration time.Duration + err *regexp.Regexp + }{ + {inp: "1s", duration: time.Second}, + {inp: "1m", duration: time.Minute}, + {inp: "1h", duration: time.Hour}, + {inp: "1d", duration: 24 * time.Hour}, + {inp: "1w", duration: 7 * 24 * time.Hour}, + {inp: "2w", duration: 2 * 7 * 24 * time.Hour}, + {inp: "1M", duration: time.Duration(730.5 * float64(time.Hour))}, + {inp: "1y", duration: 365.25 * 24 * time.Hour}, + {inp: "5y", duration: 5 * 365.25 * 24 * time.Hour}, + {inp: "invalid-duration", err: regexp.MustCompile(`^time: invalid duration "?invalid-duration"?$`)}, + } + for i, tc := range tcs { + t.Run(fmt.Sprintf("testcase %d", i), func(t *testing.T) { + res, err := ParseDuration(tc.inp) + if tc.err == nil { + require.NoError(t, err, "input %q", tc.inp) + require.Equal(t, tc.duration, res, "input %q", tc.inp) + } else { + require.Error(t, err, "input %q", tc.inp) + require.Regexp(t, tc.err, err.Error()) + } + }) + } +} + +func calculateDays() (int, int) { + now := time.Now().UTC() + currentYear, currentMonth, _ := now.Date() + + firstDayOfMonth := time.Date(currentYear, currentMonth, 1, 0, 0, 0, 0, time.UTC) + daysInMonth := firstDayOfMonth.AddDate(0, 1, -1).Day() + + t1 := time.Date(currentYear, 1, 1, 0, 0, 0, 0, time.UTC) + t2 := time.Date(currentYear+1, 1, 1, 0, 0, 0, 0, time.UTC) + + daysInYear := int(t2.Sub(t1).Hours() / 24) + + return daysInMonth, daysInYear +} + +func calculateDays5y() int { + now := time.Now().UTC() + currentYear, _, _ := now.Date() + + var daysInYear int + + for i := 0; i < 5; i++ { + t1 := time.Date(currentYear+i, 1, 1, 0, 0, 0, 0, time.UTC) + t2 := time.Date(currentYear+i+1, 1, 1, 0, 0, 0, 0, time.UTC) + + daysInYear += int(t2.Sub(t1).Hours() / 24) + } + + return daysInYear +}