▍ humdrum codex / ticktock v0.0.2

feat(grid): paint LOGGED column cells with row to entry key lookup

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

parent deeffb2d

2 files changed

internal/tui/grid/paint.go +75 −0
@@ -0,0 +1,75 @@
+package grid
+
+import (
+	"fmt"
+	"strings"
+	"time"
+
+	"ticktock/internal/store"
+)
+
+// CellKind tags a LOGGED-column slot.
+type CellKind int
+
+const (
+	CellEmpty CellKind = iota
+	CellLabel
+	CellCont
+)
+
+// Cell is one LOGGED-column slot.
+type Cell struct {
+	Kind CellKind
+	Key  string
+	Text string
+}
+
+func hmm(d time.Duration) string {
+	total := int(d.Minutes())
+	return fmt.Sprintf("%d:%02d", total/60, total%60)
+}
+
+// labelFor renders a session's LOGGED label.
+func labelFor(e store.Entry) string {
+	tags := ""
+	if len(e.Tags) > 0 {
+		tags = " [" + strings.Join(e.Tags, ",") + "]"
+	}
+	return fmt.Sprintf("✓ %s%s (%s)", e.Description, tags, hmm(e.Duration))
+}
+
+// paintLogged builds the LOGGED column: one label per session on its first
+// covered slot, continuation glyphs on later covered slots. Later-start wins a
+// contested slot. Running entries run to the last row.
+func paintLogged(g Grid, entries []store.Entry) []Cell {
+	cells := make([]Cell, g.Rows)
+	for _, e := range entries {
+		startRow := g.RowOf(minuteOfDay(e.Start))
+		var endRow int
+		if e.Running() {
+			endRow = g.Rows - 1
+		} else {
+			// end is exclusive; last covered slot holds end-1 minute
+			endRow = g.RowOf(minuteOfDay(e.End) - 1)
+		}
+		if endRow < startRow {
+			endRow = startRow
+		}
+		cells[startRow] = Cell{Kind: CellLabel, Key: e.Key, Text: labelFor(e)}
+		for r := startRow + 1; r <= endRow && r < g.Rows; r++ {
+			cells[r] = Cell{Kind: CellCont, Key: e.Key}
+		}
+	}
+	return cells
+}
+
+// entryKeyAtRow returns the Key painted at row, if any.
+func entryKeyAtRow(cells []Cell, row int) (string, bool) {
+	if row < 0 || row >= len(cells) {
+		return "", false
+	}
+	if cells[row].Kind == CellEmpty || cells[row].Key == "" {
+		return "", false
+	}
+	return cells[row].Key, true
+}
internal/tui/grid/paint_test.go +48 −0
@@ -0,0 +1,48 @@
+package grid
+
+import (
+	"testing"
+
+	"ticktock/internal/store"
+)
+
+func TestPaintLoggedLabelAndContinuation(t *testing.T) {
+	g := BuildGrid(30, 7*60, 9*60, nil) // FirstMin 420, Rows 4 (07:00..09:00)
+	es := []store.Entry{{
+		Key: "2026-07-07-01", Start: at(7, 0), End: at(8, 30),
+		Description: "work", Duration: 90 * 60 * 1e9,
+	}}
+	cells := paintLogged(g, es)
+	if cells[0].Kind != CellLabel {
+		t.Fatalf("row0 kind=%v, want Label", cells[0].Kind)
+	}
+	if cells[1].Kind != CellCont || cells[2].Kind != CellCont {
+		t.Errorf("rows1-2 should be continuation")
+	}
+	if cells[3].Kind != CellEmpty {
+		t.Errorf("row3 should be empty (session ends 08:30)")
+	}
+	if cells[0].Key != "2026-07-07-01" || cells[2].Key != "2026-07-07-01" {
+		t.Errorf("key not propagated to covered slots")
+	}
+}
+
+func TestEntryKeyAtRow(t *testing.T) {
+	g := BuildGrid(30, 7*60, 9*60, nil)
+	es := []store.Entry{{Key: "K", Start: at(7, 30), End: at(8, 0), Description: "x"}}
+	cells := paintLogged(g, es)
+	if k, ok := entryKeyAtRow(cells, 1); !ok || k != "K" {
+		t.Errorf("row1 → %q,%v want K,true", k, ok)
+	}
+	if _, ok := entryKeyAtRow(cells, 0); ok {
+		t.Errorf("row0 empty should be false")
+	}
+}
+
+func TestLabelForFormatsDurationAndTags(t *testing.T) {
+	e := store.Entry{Description: "emails", Tags: []string{"admin"}, Duration: 50 * 60 * 1e9}
+	got := labelFor(e)
+	if got != "✓ emails [admin] (0:50)" {
+		t.Errorf("labelFor=%q", got)
+	}
+}