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