▍ humdrum codex / ticktock v0.0.2

fix(grid): skip pre-decided times in add form, clearer delete target, aligned columns

bc8bdbcbfe1f873f750e8b6ee81d723bffce6454
Kevin Kortum <kevinkortum@me.com> · 2026-07-07 21:59

parent e95af753

fix(grid): skip pre-decided times in add form, clearer delete target, aligned columns

- add form (space→arrow→space) now shows only project/desc/tags/note so the
  cursor lands on project; the selected range is shown above the form
- LOGGED cursor cell is highlighted so it's obvious what enter/d will act on
- delete confirm names the session (description + time range) and highlights
  its rows in red instead of showing only the tock key
- fixed-gutter row layout aligns the column separators across header and body

5 files changed

internal/tui/form/form.go +11 −4
@@ -70,9 +70,16 @@ 	v.Tags, v.Note = strings.Join(e.Tags, ", "), e.Notes
 	return huh.NewForm(huh.NewGroup(inputs(v)...))
 }
 
-// Blank prefills *v with start/end/project (empty desc/tags/note) for adding a
-// new entry from a grid range, and returns the form bound to v.
-func Blank(start, end, project string, v *Values) *huh.Form {
+// AddForm builds a form for a new entry whose start/end are already fixed by a
+// grid selection. It stores start/end/project into *v but shows only the
+// project/description/tags/note inputs, so the cursor lands on project instead
+// of tabbing through the pre-decided times.
+func AddForm(start, end, project string, v *Values) *huh.Form {
 	*v = Values{Start: start, End: end, Project: project}
-	return huh.NewForm(huh.NewGroup(inputs(v)...))
+	return huh.NewForm(huh.NewGroup(
+		huh.NewInput().Title("project").Value(&v.Project),
+		huh.NewInput().Title("description").Value(&v.Desc),
+		huh.NewInput().Title("tags (comma-sep)").Value(&v.Tags),
+		huh.NewInput().Title("note").Value(&v.Note),
+	))
 }
internal/tui/form/form_test.go +4 −4
@@ -43,13 +43,13 @@ 		t.Fatal("expected error on bad start time")
 	}
 }
 
-func TestBlankPrefillsStartEndProject(t *testing.T) {
+func TestAddFormPrefillsStartEndProject(t *testing.T) {
 	var v Values
-	_ = Blank("10:00", "10:30", "ARCHER", &v)
+	_ = AddForm("10:00", "10:30", "ARCHER", &v)
 	if v.Start != "10:00" || v.End != "10:30" || v.Project != "ARCHER" {
-		t.Errorf("blank prefill wrong: %+v", v)
+		t.Errorf("add-form prefill wrong: %+v", v)
 	}
 	if v.Desc != "" || v.Tags != "" || v.Note != "" {
-		t.Errorf("blank should leave desc/tags/note empty: %+v", v)
+		t.Errorf("add-form should leave desc/tags/note empty: %+v", v)
 	}
 }
internal/tui/grid/editdelete_test.go +11 −0
@@ -1,6 +1,7 @@
 package grid
 
 import (
+	"strings"
 	"testing"
 
 	tea "github.com/charmbracelet/bubbletea"
@@ -63,6 +64,16 @@ 	}
 	nm, _ = mm.Update(key("y"))
 	if len(f.removed) != 1 || f.removed[0] != "2026-07-07-01" {
 		t.Fatalf("y should confirm remove: %v", f.removed)
+	}
+}
+
+func TestConfirmLabelDescribesSession(t *testing.T) {
+	f := loggedFixture()
+	m := onLoggedRow(loaded(f))
+	nm, _ := m.Update(key("d"))
+	label := nm.(Model).confirmLabel()
+	if !strings.Contains(label, "standup") || !strings.Contains(label, "08:00") {
+		t.Errorf("confirm label should name the session and range, got %q", label)
 	}
 }
 
internal/tui/grid/model.go +1 −1
@@ -216,7 +216,7 @@ 	m.anchor = nil
 	start, end := m.rangeTimes(lo, hi)
 	m.editing, m.adding = true, true
 	m.fv = &form.Values{}
-	m.form = form.Blank(start, end, m.cfg.Project, m.fv)
+	m.form = form.AddForm(start, end, m.cfg.Project, m.fv)
 	return m, m.form.Init()
 }
 
internal/tui/grid/view.go +70 −18
@@ -13,12 +13,15 @@ 	gPink   = lipgloss.Color("#D188C4")
 	gMuted  = lipgloss.Color("#878580")
 	gGreen  = lipgloss.Color("#78B892")
 	gPurple = lipgloss.Color("#5A2FE0")
+	gRed    = lipgloss.Color("#D16969")
 
 	gHeader = lipgloss.NewStyle().Foreground(gPink).Bold(true)
 	gDim    = lipgloss.NewStyle().Foreground(gMuted)
 	gLabel  = lipgloss.NewStyle().Foreground(gGreen)
 	gBand   = lipgloss.NewStyle().Foreground(gPurple)
 	gCursor = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Background(gPurple)
+	gCurLog = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Background(gPurple).Bold(true)
+	gDelHi  = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Background(gRed).Bold(true)
 )
 
 func gHeaderLine(date string) string {
@@ -50,57 +53,88 @@ 	}
 	return string(r[:w-1]) + "…"
 }
 
+// gridRow renders one line with a fixed 1-col gutter so every row's separators
+// align: [gutter] time(5) │ activity(actWidth) │ logged.
+func gridRow(gutter, timeStr, actCell, logCell string) string {
+	return fmt.Sprintf("%s %-5s │ %-*s │ %s", gutter, timeStr, actWidth, actCell, logCell)
+}
+
+// ruleLine draws the header underline with ┼ crossings at the │ columns.
+func ruleLine() string {
+	// gridRow prefix before first │: gutter(1)+sp(1)+time(5)+sp(1) = 8 chars.
+	return gDim.Render(
+		strings.Repeat("─", 8) + "┼" +
+			strings.Repeat("─", actWidth+2) + "┼" +
+			strings.Repeat("─", 14))
+}
+
 func (m Model) View() string {
 	if m.quit {
 		return ""
 	}
 	if m.editing && m.form != nil {
+		head := gHeaderLine(m.date)
+		if m.adding && m.fv != nil {
+			head += "\n" + gDim.Render(fmt.Sprintf("new entry  %s–%s", m.fv.Start, m.fv.End))
+		}
 		hint := gDim.Render("ctrl+s save · esc cancel")
-		return gHeaderLine(m.date) + "\n\n" + m.form.View() + "\n" + hint
+		return head + "\n\n" + m.form.View() + "\n" + hint
 	}
 
 	var b strings.Builder
 	b.WriteString(gHeaderLine(m.date) + "\n\n")
-	// column headers
-	b.WriteString(fmt.Sprintf(" %-5s │ %-*s │ %s\n", "TIME", actWidth, "ACTIVITY", "LOGGED → tock"))
-	b.WriteString(gDim.Render(" ─────┼─"+strings.Repeat("─", actWidth)+"┼─────────────") + "\n")
+	b.WriteString(gridRow(" ", "TIME", "ACTIVITY", "LOGGED → tock") + "\n")
+	b.WriteString(ruleLine() + "\n")
 
 	lo, hi, selecting := m.selectionRange()
 	for row := 0; row < m.grid.Rows; row++ {
-		tcol := gDim.Render(fmt.Sprintf(" %-5s", minuteLabel(m.grid.SlotMinute(row))))
+		onCursor := row == m.cursor
 
 		// ACTIVITY column: gap lane, band if selected
-		gap := strings.Repeat("░", actWidth)
+		act := strings.Repeat("░", actWidth)
 		if selecting && row >= lo && row <= hi {
-			gap = gBand.Render(strings.Repeat("▓", actWidth))
+			act = gBand.Render(strings.Repeat("▓", actWidth))
 		}
 
 		// LOGGED column
 		var logged string
 		if row < len(m.logged) {
-			switch m.logged[row].Kind {
+			c := m.logged[row]
+			switch c.Kind {
 			case CellLabel:
-				logged = gLabel.Render(trunc(m.logged[row].Text, loggedWidth))
+				logged = trunc(c.Text, loggedWidth)
 			case CellCont:
-				logged = gDim.Render("│")
+				logged = "│"
+			}
+			// highlight the whole session targeted for deletion
+			if m.confirmDelete && c.Key != "" && c.Key == m.confirmKey {
+				logged = gDelHi.Render(logged)
+			} else if onCursor && m.col == ColLogged && c.Kind != CellEmpty {
+				logged = gCurLog.Render(logged)
+			} else if c.Kind == CellLabel {
+				logged = gLabel.Render(logged)
+			} else if c.Kind == CellCont {
+				logged = gDim.Render(logged)
 			}
 		}
 
-		// cursor marker on the active row
-		cursorMark := " "
-		if row == m.cursor {
-			cursorMark = gCursor.Render(">")
+		gutter := " "
+		if onCursor {
+			gutter = gCursor.Render(">")
 		}
-		b.WriteString(fmt.Sprintf("%s%s │ %s │ %s\n", cursorMark, tcol, gap, logged))
+		timeStr := gDim.Render(minuteLabel(m.grid.SlotMinute(row)))
+		b.WriteString(gridRow(gutter, timeStr, act, logged) + "\n")
 	}
 
 	b.WriteString("\n")
+	if m.confirmDelete {
+		// pre-styled; write without an outer dim wrap
+		b.WriteString(gDelHi.Render(" delete ") + " " + m.confirmLabel() + "   " + gDim.Render("y = yes · n = no"))
+		return b.String()
+	}
 	help := "←/→ col · ↑/↓ slot · space select/add · enter edit · d delete · n/p/t day · r reload · q quit"
 	if m.status != "" {
 		help = m.status
-	}
-	if m.confirmDelete {
-		help = "delete " + m.confirmKey + "?  y = yes · n = no"
 	}
 	if m.err != nil {
 		help = "error: " + m.err.Error()
@@ -108,3 +142,21 @@ 	}
 	b.WriteString(gDim.Render(help))
 	return b.String()
 }
+
+// confirmLabel describes the session pending deletion (description + range),
+// falling back to the tock key if the entry can't be found.
+func (m Model) confirmLabel() string {
+	e, ok := m.entryByKey(m.confirmKey)
+	if !ok {
+		return m.confirmKey
+	}
+	rng := e.Start.Format("15:04") + "–" + e.End.Format("15:04")
+	if e.Running() {
+		rng = e.Start.Format("15:04") + "–…"
+	}
+	desc := e.Description
+	if desc == "" {
+		desc = e.Project
+	}
+	return fmt.Sprintf("%q  %s", desc, rng)
+}