▍ humdrum codex / dots v1.0.0

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)
+	}
+}