▍ humdrum codex / ticktock v0.0.2

feat(grid): slot geometry with auto-expanding day window

deeffb2d9aa093c0f1fe54506af77ce98c3827ab
Kevin Kortum <kevinkortum@me.com> · 2026-07-07 21:43

parent fd22a566

2 files changed

internal/tui/grid/geometry.go +75 −0
@@ -0,0 +1,75 @@
+package grid
+
+import (
+	"fmt"
+	"time"
+
+	"ticktock/internal/store"
+)
+
+// Grid describes the fixed-slot day window.
+type Grid struct {
+	SlotMin  int // minutes per slot
+	FirstMin int // minute-of-day at row 0, slot-aligned
+	Rows     int // number of slots
+}
+
+func parseHM(s string) (int, error) {
+	t, err := time.Parse("15:04", s)
+	if err != nil {
+		return 0, fmt.Errorf("time %q: %w", s, err)
+	}
+	return t.Hour()*60 + t.Minute(), nil
+}
+
+func minuteOfDay(t time.Time) int { return t.Hour()*60 + t.Minute() }
+
+func floorTo(min, slot int) int { return (min / slot) * slot }
+func ceilTo(min, slot int) int  { return ((min + slot - 1) / slot) * slot }
+
+// BuildGrid computes the slot window: [startMin,endMin] from config, expanded
+// outward to cover every entry's start and (if ended) end. Running entries
+// contribute only their start.
+func BuildGrid(slot, startMin, endMin int, entries []store.Entry) Grid {
+	first, last := startMin, endMin
+	for _, e := range entries {
+		s := minuteOfDay(e.Start)
+		if s < first {
+			first = s
+		}
+		if !e.Running() {
+			en := minuteOfDay(e.End)
+			if en > last {
+				last = en
+			}
+		} else if s > last {
+			last = s
+		}
+	}
+	first = floorTo(first, slot)
+	last = ceilTo(last, slot)
+	rows := (last - first) / slot
+	if rows < 1 {
+		rows = 1
+	}
+	return Grid{SlotMin: slot, FirstMin: first, Rows: rows}
+}
+
+// SlotMinute returns the minute-of-day at the start of the given row. Passing
+// row == Rows yields the window end (one past the last slot).
+func (g Grid) SlotMinute(row int) int { return g.FirstMin + row*g.SlotMin }
+
+// RowOf returns the row containing minute, clamped to the grid.
+func (g Grid) RowOf(min int) int {
+	r := (min - g.FirstMin) / g.SlotMin
+	if min < g.FirstMin {
+		r = 0
+	}
+	if r < 0 {
+		r = 0
+	}
+	if r > g.Rows-1 {
+		r = g.Rows - 1
+	}
+	return r
+}
internal/tui/grid/geometry_test.go +69 −0
@@ -0,0 +1,69 @@
+package grid
+
+import (
+	"testing"
+	"time"
+
+	"ticktock/internal/store"
+)
+
+func at(h, m int) time.Time {
+	return time.Date(2026, 7, 7, h, m, 0, 0, time.Local)
+}
+
+func TestParseHM(t *testing.T) {
+	m, err := parseHM("07:30")
+	if err != nil || m != 450 {
+		t.Fatalf("got %d err %v, want 450", m, err)
+	}
+	if _, err := parseHM("nope"); err == nil {
+		t.Fatal("want error")
+	}
+}
+
+func TestBuildGridAlignsAndCountsRows(t *testing.T) {
+	// window 07:00..09:00, slot 30 → rows at 07:00,07:30,08:00,08:30 = 4
+	g := BuildGrid(30, 7*60, 9*60, nil)
+	if g.FirstMin != 420 || g.Rows != 4 {
+		t.Fatalf("got FirstMin=%d Rows=%d, want 420,4", g.FirstMin, g.Rows)
+	}
+}
+
+func TestBuildGridExpandsToCoverEntries(t *testing.T) {
+	// entry 06:10..09:40 must expand window 07:00..08:00 outward, slot 30:
+	// first floors 06:10 → 06:00 (360); last ceils 09:40 → 10:00 (600)
+	es := []store.Entry{{Start: at(6, 10), End: at(9, 40)}}
+	g := BuildGrid(30, 7*60, 8*60, es)
+	if g.FirstMin != 360 {
+		t.Errorf("FirstMin=%d, want 360", g.FirstMin)
+	}
+	last := g.SlotMinute(g.Rows) // one past the last row = window end
+	if last != 600 {
+		t.Errorf("window end=%d, want 600", last)
+	}
+}
+
+func TestRowOfRoundTripAndClamp(t *testing.T) {
+	g := BuildGrid(30, 7*60, 9*60, nil) // FirstMin 420, Rows 4
+	if g.RowOf(420) != 0 || g.RowOf(449) != 0 || g.RowOf(450) != 1 {
+		t.Errorf("RowOf boundaries wrong")
+	}
+	if g.RowOf(0) != 0 {
+		t.Errorf("below window should clamp to 0")
+	}
+	if g.RowOf(100000) != g.Rows-1 {
+		t.Errorf("above window should clamp to last row")
+	}
+	if g.SlotMinute(2) != 420+60 {
+		t.Errorf("SlotMinute wrong")
+	}
+}
+
+func TestBuildGridRunningEntryUsesStartOnly(t *testing.T) {
+	es := []store.Entry{{Start: at(20, 10)}} // End zero → running
+	g := BuildGrid(30, 7*60, 21*60, es)
+	// running start 20:10 is inside default window; end stays 21:00
+	if g.SlotMinute(g.Rows) != 21*60 {
+		t.Errorf("running entry must not expand end past window")
+	}
+}