feat: slot math — target parsing, time syntax detection, labels
b08829a390575522cb979147aeed165597925bb4
humdrum-tiv <45084903+humdrum-tiv@users.noreply.github.com> · 2026-07-29 16:48
parent 7372cce7
feat: slot math — target parsing, time syntax detection, labels fix: error handling in parseMinutes + test coverage for edge cases
2 files changed
internal/day/slots.go +75 −0
@@ -0,0 +1,75 @@
+package day
+
+import (
+ "fmt"
+ "regexp"
+ "strconv"
+ "time"
+)
+
+var timeRe = regexp.MustCompile(`^(\d{1,2}):(\d{2})$`)
+var rangeRe = regexp.MustCompile(`^(\d{1,2}:\d{2})-(\d{1,2}:\d{2})$`)
+
+// IsTimeSyntax reports whether arg looks like a time or time range,
+// deciding whether a positional arg is a target or an activity name.
+func IsTimeSyntax(arg string) bool {
+ return timeRe.MatchString(arg) || rangeRe.MatchString(arg)
+}
+
+func parseMinutes(s string) (int, error) {
+ m := timeRe.FindStringSubmatch(s)
+ if m == nil {
+ return 0, fmt.Errorf("invalid time %q (want H:MM)", s)
+ }
+ h, err := strconv.Atoi(m[1])
+ if err != nil {
+ return 0, fmt.Errorf("invalid time %q", s)
+ }
+ min, err := strconv.Atoi(m[2])
+ if err != nil {
+ return 0, fmt.Errorf("invalid time %q", s)
+ }
+ if h > 23 || min > 59 {
+ return 0, fmt.Errorf("invalid time %q", s)
+ }
+ return h*60 + min, nil
+}
+
+// ParseTarget resolves a time argument to canonical block indices.
+// arg "" means the slot containing now. Ranges are end-exclusive.
+func ParseTarget(arg string, blockSize int, now time.Time) ([]int, error) {
+ if arg == "" {
+ return []int{(now.Hour()*60 + now.Minute()) / blockSize}, nil
+ }
+ if m := rangeRe.FindStringSubmatch(arg); m != nil {
+ start, err := parseMinutes(m[1])
+ if err != nil {
+ return nil, err
+ }
+ end, err := parseMinutes(m[2])
+ if err != nil {
+ return nil, err
+ }
+ if end <= start {
+ return nil, fmt.Errorf("range end %s is not after start %s", m[2], m[1])
+ }
+ first := start / blockSize
+ last := (end - 1) / blockSize
+ out := make([]int, 0, last-first+1)
+ for i := first; i <= last; i++ {
+ out = append(out, i)
+ }
+ return out, nil
+ }
+ mins, err := parseMinutes(arg)
+ if err != nil {
+ return nil, err
+ }
+ return []int{mins / blockSize}, nil
+}
+
+// SlotLabel renders a block index as its start time, e.g. "14:30".
+func SlotLabel(index, blockSize int) string {
+ mins := index * blockSize
+ return fmt.Sprintf("%d:%02d", mins/60, mins%60)
+}
internal/day/slots_test.go +82 −0
@@ -0,0 +1,82 @@
+package day
+
+import (
+ "reflect"
+ "testing"
+ "time"
+)
+
+func at(h, m int) time.Time {
+ return time.Date(2026, 7, 29, h, m, 0, 0, time.UTC)
+}
+
+func TestParseTarget(t *testing.T) {
+ cases := []struct {
+ name string
+ arg string
+ blockSize int
+ now time.Time
+ want []int
+ wantErr bool
+ }{
+ {"empty is current slot", "", 30, at(14, 47), []int{29}, false},
+ {"single time", "14:30", 30, at(0, 0), []int{29}, false},
+ {"snap down", "14:47", 30, at(0, 0), []int{29}, false},
+ {"single digit hour", "9:00", 30, at(0, 0), []int{18}, false},
+ {"leading zero", "09:00", 30, at(0, 0), []int{18}, false},
+ {"range end exclusive", "13:00-15:00", 30, at(0, 0), []int{26, 27, 28, 29}, false},
+ {"range at 15min blocks", "13:00-14:00", 15, at(0, 0), []int{52, 53, 54, 55}, false},
+ {"range at 60min blocks", "13:30-15:00", 60, at(0, 0), []int{13, 14}, false},
+ {"midnight slot", "0:00", 30, at(0, 0), []int{0}, false},
+ {"last slot", "23:30", 30, at(0, 0), []int{47}, false},
+ {"end before start", "15:00-13:00", 30, at(0, 0), nil, true},
+ {"end equals start", "13:00-13:00", 30, at(0, 0), nil, true},
+ {"hour out of range", "24:00", 30, at(0, 0), nil, true},
+ {"minute out of range", "12:60", 30, at(0, 0), nil, true},
+ {"garbage", "archer", 30, at(0, 0), nil, true},
+ {"single digit minute", "1:5", 30, at(0, 0), nil, true},
+ {"three digit hour", "007:00", 30, at(0, 0), nil, true},
+ {"leading space", " 9:00", 30, at(0, 0), nil, true},
+ {"incomplete range end", "9:00-", 30, at(0, 0), nil, true},
+ {"hyphen prefix", "-9:00", 30, at(0, 0), nil, true},
+ {"double hyphen in range", "9:00--15:00", 30, at(0, 0), nil, true},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ got, err := ParseTarget(c.arg, c.blockSize, c.now)
+ if c.wantErr != (err != nil) {
+ t.Fatalf("err = %v, wantErr %v", err, c.wantErr)
+ }
+ if !c.wantErr && !reflect.DeepEqual(got, c.want) {
+ t.Fatalf("got %v, want %v", got, c.want)
+ }
+ })
+ }
+}
+
+func TestIsTimeSyntax(t *testing.T) {
+ yes := []string{"14:30", "9:00", "09:00", "13:00-15:00"}
+ no := []string{"archer", "", "14", "14:3", "a-b", "14:30ish", "1:5", " 9:00", "9:00-"}
+ for _, s := range yes {
+ if !IsTimeSyntax(s) {
+ t.Errorf("IsTimeSyntax(%q) = false, want true", s)
+ }
+ }
+ for _, s := range no {
+ if IsTimeSyntax(s) {
+ t.Errorf("IsTimeSyntax(%q) = true, want false", s)
+ }
+ }
+}
+
+func TestSlotLabel(t *testing.T) {
+ if got := SlotLabel(29, 30); got != "14:30" {
+ t.Fatalf("got %q", got)
+ }
+ if got := SlotLabel(0, 30); got != "0:00" {
+ t.Fatalf("got %q", got)
+ }
+ if got := SlotLabel(55, 15); got != "13:45" {
+ t.Fatalf("got %q", got)
+ }
+}