Add forecast daypart derivation
This commit is contained in:
66
internal/timeutil/periods.go
Normal file
66
internal/timeutil/periods.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package timeutil
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Period struct {
|
||||
Start time.Time `json:"start"`
|
||||
End time.Time `json:"end"`
|
||||
}
|
||||
|
||||
func (p Period) IsValid() bool {
|
||||
return p.End.After(p.Start)
|
||||
}
|
||||
|
||||
func (p Period) Overlaps(other Period) bool {
|
||||
return p.Start.Before(other.End) && other.Start.Before(p.End)
|
||||
}
|
||||
|
||||
func (p Period) Contains(t time.Time) bool {
|
||||
return !t.Before(p.Start) && t.Before(p.End)
|
||||
}
|
||||
|
||||
func ParseClock(value string) (time.Duration, error) {
|
||||
parts := strings.Split(value, ":")
|
||||
if len(parts) != 2 {
|
||||
return 0, fmt.Errorf("expected HH:MM")
|
||||
}
|
||||
hour, err := strconv.Atoi(parts[0])
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid hour")
|
||||
}
|
||||
minute, err := strconv.Atoi(parts[1])
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid minute")
|
||||
}
|
||||
if hour < 0 || hour > 24 {
|
||||
return 0, fmt.Errorf("hour must be between 00 and 24")
|
||||
}
|
||||
if minute < 0 || minute > 59 {
|
||||
return 0, fmt.Errorf("minute must be between 00 and 59")
|
||||
}
|
||||
if hour == 24 && minute != 0 {
|
||||
return 0, fmt.Errorf("24 is only valid as 24:00")
|
||||
}
|
||||
return time.Duration(hour)*time.Hour + time.Duration(minute)*time.Minute, nil
|
||||
}
|
||||
|
||||
func CivilDay(date time.Time, location *time.Location) Period {
|
||||
local := date.In(location)
|
||||
start := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, location)
|
||||
return Period{Start: start, End: start.AddDate(0, 0, 1)}
|
||||
}
|
||||
|
||||
func ClockWindow(date time.Time, location *time.Location, startClock time.Duration, endClock time.Duration) Period {
|
||||
day := CivilDay(date, location)
|
||||
start := day.Start.Add(startClock)
|
||||
end := day.Start.Add(endClock)
|
||||
if !end.After(start) {
|
||||
end = end.AddDate(0, 0, 1)
|
||||
}
|
||||
return Period{Start: start, End: end}
|
||||
}
|
||||
35
internal/timeutil/periods_test.go
Normal file
35
internal/timeutil/periods_test.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package timeutil
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestPeriodOverlapUsesHalfOpenIntervals(t *testing.T) {
|
||||
start := time.Date(2026, 5, 29, 6, 0, 0, 0, time.UTC)
|
||||
left := Period{Start: start, End: start.Add(time.Hour)}
|
||||
touching := Period{Start: start.Add(time.Hour), End: start.Add(2 * time.Hour)}
|
||||
overlapping := Period{Start: start.Add(30 * time.Minute), End: start.Add(90 * time.Minute)}
|
||||
|
||||
if left.Overlaps(touching) {
|
||||
t.Fatal("touching half-open periods overlap, want false")
|
||||
}
|
||||
if !left.Overlaps(overlapping) {
|
||||
t.Fatal("overlapping periods do not overlap, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClockWindowHandlesOvernight(t *testing.T) {
|
||||
location := time.FixedZone("Test", -5*60*60)
|
||||
date := time.Date(2026, 5, 29, 12, 0, 0, 0, location)
|
||||
start, _ := ParseClock("22:00")
|
||||
end, _ := ParseClock("06:00")
|
||||
|
||||
window := ClockWindow(date, location, start, end)
|
||||
if got := window.Start.Format("2006-01-02T15:04"); got != "2026-05-29T22:00" {
|
||||
t.Fatalf("Start = %s, want 2026-05-29T22:00", got)
|
||||
}
|
||||
if got := window.End.Format("2006-01-02T15:04"); got != "2026-05-30T06:00" {
|
||||
t.Fatalf("End = %s, want 2026-05-30T06:00", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user