▍ humdrum codex / ticktock v0.0.2

feat(grid): bubbletea model, 3-column render, cursor + day nav

a867de7ee005830a555ae7b0eb56ec41159bed90
Kevin Kortum <kevinkortum@me.com> · 2026-07-07 21:45

parent c9cc052d

3 files changed

internal/tui/grid/model.go +321 −0
@@ -0,0 +1,321 @@
+package grid
+
+import (
+	"time"
+
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/huh"
+
+	"ticktock/internal/config"
+	"ticktock/internal/store"
+	"ticktock/internal/tui/form"
+)
+
+// Column identifies the focusable column.
+type Column int
+
+const (
+	ColActivity Column = iota
+	ColLogged
+)
+
+// Model is the grid-view Bubble Tea model.
+type Model struct {
+	store store.Store
+	cfg   config.Config
+	date  string
+
+	entries []store.Entry
+	grid    Grid
+	logged  []Cell
+
+	cursor int
+	col    Column
+	anchor *int // non-nil while a selection is in progress
+
+	width  int
+	height int
+
+	// editing state (Tasks 6-7)
+	editing  bool
+	adding   bool
+	form     *huh.Form
+	fv       *form.Values
+	editKey  string
+	editOrig store.Entry
+
+	confirmDelete bool
+	confirmKey    string
+
+	status string
+	err    error
+	quit   bool
+}
+
+type entriesMsg struct {
+	entries []store.Entry
+	err     error
+}
+
+// New builds a grid model for date (YYYY-MM-DD).
+func New(s store.Store, cfg config.Config, date string) Model {
+	return Model{store: s, cfg: cfg, date: date, col: ColActivity}
+}
+
+func (m Model) Init() tea.Cmd { return m.load() }
+
+func (m Model) load() tea.Cmd {
+	date, s := m.date, m.store
+	return func() tea.Msg {
+		es, err := s.Day(date)
+		return entriesMsg{entries: es, err: err}
+	}
+}
+
+// rebuild recomputes the grid window and painted cells from current entries.
+func (m *Model) rebuild() {
+	startMin, err := parseHM(m.cfg.GridStart)
+	if err != nil {
+		startMin = 7 * 60
+	}
+	endMin, err := parseHM(m.cfg.GridEnd)
+	if err != nil {
+		endMin = 21 * 60
+	}
+	m.grid = BuildGrid(m.cfg.SlotMinutes, startMin, endMin, m.entries)
+	m.logged = paintLogged(m.grid, m.entries)
+	if m.cursor > m.grid.Rows-1 {
+		m.cursor = m.grid.Rows - 1
+	}
+	if m.cursor < 0 {
+		m.cursor = 0
+	}
+}
+
+// selectionRange returns the inclusive band [lo,hi] and whether a selection is
+// active.
+func (m Model) selectionRange() (int, int, bool) {
+	if m.anchor == nil {
+		return 0, 0, false
+	}
+	lo, hi := *m.anchor, m.cursor
+	if hi < lo {
+		lo, hi = hi, lo
+	}
+	return lo, hi, true
+}
+
+func shiftDate(date string, days int) string {
+	t, err := time.ParseInLocation("2006-01-02", date, time.Local)
+	if err != nil {
+		return date
+	}
+	return t.AddDate(0, 0, days).Format("2006-01-02")
+}
+
+func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	if m.confirmDelete {
+		if km, ok := msg.(tea.KeyMsg); ok {
+			switch km.String() {
+			case "y":
+				m.confirmDelete = false
+				key := m.confirmKey
+				m.confirmKey = ""
+				if err := m.store.Remove(key); err != nil {
+					m.status = "delete failed: " + err.Error()
+				} else {
+					m.status = "removed " + key
+				}
+				return m, m.load()
+			case "n", "esc":
+				m.confirmDelete = false
+				m.confirmKey = ""
+				m.status = "delete cancelled"
+				return m, nil
+			}
+		}
+		return m, nil
+	}
+	if m.editing {
+		return m.updateEditing(msg)
+	}
+	switch msg := msg.(type) {
+	case entriesMsg:
+		m.entries, m.err = msg.entries, msg.err
+		m.rebuild()
+		return m, nil
+	case tea.WindowSizeMsg:
+		m.width, m.height = msg.Width, msg.Height
+		return m, nil
+	case tea.KeyMsg:
+		return m.handleKey(msg)
+	}
+	return m, nil
+}
+
+func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+	switch msg.String() {
+	case "q", "ctrl+c":
+		m.quit = true
+		return m, tea.Quit
+	case "left":
+		m.col = ColActivity
+		return m, nil
+	case "right":
+		m.col = ColLogged
+		return m, nil
+	case "up":
+		if m.cursor > 0 {
+			m.cursor--
+		}
+		return m, nil
+	case "down":
+		if m.cursor < m.grid.Rows-1 {
+			m.cursor++
+		}
+		return m, nil
+	case "esc":
+		m.anchor = nil
+		return m, nil
+	case " ":
+		return m.handleSpace()
+	case "enter":
+		return m.handleEnter()
+	case "d":
+		return m.handleDelete()
+	case "n":
+		m.date = shiftDate(m.date, -1)
+		m.anchor = nil
+		return m, m.load()
+	case "p":
+		m.date = shiftDate(m.date, 1)
+		m.anchor = nil
+		return m, m.load()
+	case "t":
+		m.date = time.Now().Format("2006-01-02")
+		m.anchor = nil
+		return m, m.load()
+	case "r":
+		return m, m.load()
+	}
+	return m, nil
+}
+
+// handleSpace anchors a selection or finalizes it into an add form.
+func (m Model) handleSpace() (tea.Model, tea.Cmd) {
+	if m.col != ColActivity {
+		return m, nil
+	}
+	if m.anchor == nil {
+		a := m.cursor
+		m.anchor = &a
+		return m, nil
+	}
+	lo, hi, _ := m.selectionRange()
+	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)
+	return m, m.form.Init()
+}
+
+// rangeTimes returns HH:MM for the selection's start slot and the slot after
+// its last (exclusive end).
+func (m Model) rangeTimes(lo, hi int) (string, string) {
+	start := minuteLabel(m.grid.SlotMinute(lo))
+	end := minuteLabel(m.grid.SlotMinute(hi) + m.grid.SlotMin)
+	return start, end
+}
+
+func (m Model) entryByKey(k string) (store.Entry, bool) {
+	for _, e := range m.entries {
+		if e.Key == k {
+			return e, true
+		}
+	}
+	return store.Entry{}, false
+}
+
+// handleEnter opens the edit form for the logged session under the cursor.
+func (m Model) handleEnter() (tea.Model, tea.Cmd) {
+	if m.col != ColLogged {
+		return m, nil
+	}
+	k, ok := entryKeyAtRow(m.logged, m.cursor)
+	if !ok {
+		return m, nil
+	}
+	e, ok := m.entryByKey(k)
+	if !ok || e.Running() {
+		return m, nil
+	}
+	m.editing, m.adding = true, false
+	m.editKey, m.editOrig = e.Key, e
+	m.fv = &form.Values{}
+	m.form = form.New(e, m.fv)
+	return m, m.form.Init()
+}
+
+// handleDelete arms the yes/no confirm for the session under the cursor.
+func (m Model) handleDelete() (tea.Model, tea.Cmd) {
+	if m.col != ColLogged {
+		return m, nil
+	}
+	k, ok := entryKeyAtRow(m.logged, m.cursor)
+	if !ok {
+		return m, nil
+	}
+	m.confirmDelete = true
+	m.confirmKey = k
+	m.status = "delete " + k + "? y/n"
+	return m, nil
+}
+
+func (m Model) updateEditing(msg tea.Msg) (tea.Model, tea.Cmd) {
+	if km, ok := msg.(tea.KeyMsg); ok {
+		switch km.String() {
+		case "esc":
+			m.editing, m.adding = false, false
+			m.form = nil
+			return m, nil
+		case "ctrl+s":
+			return m.finishEditing()
+		}
+	}
+	f, cmd := m.form.Update(msg)
+	if ff, ok := f.(*huh.Form); ok {
+		m.form = ff
+	}
+	if m.form.State == huh.StateCompleted {
+		return m.finishEditing()
+	}
+	return m, cmd
+}
+
+// finishEditing routes to Add (new range) or SafeReplace (existing) by intent.
+func (m Model) finishEditing() (tea.Model, tea.Cmd) {
+	adding := m.adding
+	m.editing, m.adding = false, false
+	up, err := form.BuildEntry(m.date, m.editOrig, *m.fv)
+	if err != nil {
+		m.status = "invalid: " + err.Error()
+		m.form = nil
+		return m, nil
+	}
+	if adding {
+		if err := m.store.Add(up); err != nil {
+			m.status = "add failed: " + err.Error()
+		} else {
+			m.status = "added entry"
+		}
+	} else {
+		if err := m.store.SafeReplace(m.editKey, up, m.editOrig); err != nil {
+			m.status = "edit failed: " + err.Error()
+		} else {
+			m.status = "updated " + m.editKey
+		}
+	}
+	m.form = nil
+	return m, m.load()
+}
internal/tui/grid/model_test.go +125 −0
@@ -0,0 +1,125 @@
+package grid
+
+import (
+	"strings"
+	"testing"
+	"time"
+
+	tea "github.com/charmbracelet/bubbletea"
+
+	"ticktock/internal/config"
+	"ticktock/internal/store"
+)
+
+type fakeStore struct {
+	entries []store.Entry
+	added   []store.Entry
+	removed []string
+	repl    []store.Entry
+}
+
+func (f *fakeStore) Day(string) ([]store.Entry, error) { return f.entries, nil }
+func (f *fakeStore) Add(e store.Entry) error           { f.added = append(f.added, e); return nil }
+func (f *fakeStore) Remove(k string) error             { f.removed = append(f.removed, k); return nil }
+func (f *fakeStore) SafeReplace(k string, u, o store.Entry) error {
+	f.repl = append(f.repl, u)
+	return nil
+}
+
+func cfg() config.Config { return config.Default() }
+
+func key(s string) tea.KeyMsg {
+	return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)}
+}
+func special(t tea.KeyType) tea.KeyMsg { return tea.KeyMsg{Type: t} }
+
+// loaded returns a model that has already ingested its entries.
+func loaded(f *fakeStore) Model {
+	m := New(f, cfg(), "2026-07-07")
+	nm, _ := m.Update(entriesMsg{entries: f.entries})
+	return nm.(Model)
+}
+
+func TestColumnToggle(t *testing.T) {
+	m := loaded(&fakeStore{})
+	if m.col != ColActivity {
+		t.Fatalf("default column should be ACTIVITY")
+	}
+	nm, _ := m.Update(special(tea.KeyRight))
+	if nm.(Model).col != ColLogged {
+		t.Errorf("right should switch to LOGGED")
+	}
+	nm, _ = nm.(Model).Update(special(tea.KeyLeft))
+	if nm.(Model).col != ColActivity {
+		t.Errorf("left should switch back to ACTIVITY")
+	}
+}
+
+func TestCursorMovesWithinBounds(t *testing.T) {
+	m := loaded(&fakeStore{})
+	up, _ := m.Update(special(tea.KeyUp))
+	if up.(Model).cursor != 0 {
+		t.Errorf("cursor should clamp at 0 going up")
+	}
+	dn, _ := m.Update(special(tea.KeyDown))
+	if dn.(Model).cursor != 1 {
+		t.Errorf("down should move to row 1")
+	}
+}
+
+func TestSpaceAnchorsThenExtends(t *testing.T) {
+	m := loaded(&fakeStore{})
+	// space anchors at row 0 (ACTIVITY column)
+	nm, _ := m.Update(key(" "))
+	mm := nm.(Model)
+	if mm.anchor == nil || *mm.anchor != 0 {
+		t.Fatalf("space should anchor at cursor 0")
+	}
+	nm, _ = mm.Update(special(tea.KeyDown))
+	mm = nm.(Model)
+	lo, hi, active := mm.selectionRange()
+	if !active || lo != 0 || hi != 1 {
+		t.Errorf("range after down = %d..%d active=%v, want 0..1 true", lo, hi, active)
+	}
+}
+
+func TestEscClearsSelection(t *testing.T) {
+	m := loaded(&fakeStore{})
+	nm, _ := m.Update(key(" "))
+	nm, _ = nm.(Model).Update(special(tea.KeyEsc))
+	if _, _, active := nm.(Model).selectionRange(); active {
+		t.Errorf("esc should clear selection")
+	}
+}
+
+func TestDayNavReloads(t *testing.T) {
+	f := &fakeStore{}
+	m := loaded(f)
+	nm, cmd := m.Update(key("n"))
+	if nm.(Model).date != "2026-07-06" {
+		t.Errorf("n should go to previous day, got %s", nm.(Model).date)
+	}
+	if cmd == nil {
+		t.Errorf("day nav should issue a reload cmd")
+	}
+	nm, _ = nm.(Model).Update(key("t"))
+	// t jumps to today; just assert it changed away from 2026-07-06
+	if nm.(Model).date == "2026-07-06" {
+		t.Errorf("t should jump to today")
+	}
+	_ = time.Now
+}
+
+func TestViewRendersColumns(t *testing.T) {
+	f := &fakeStore{entries: []store.Entry{{
+		Key: "2026-07-07-01", Start: at(8, 0), End: at(8, 30), Description: "standup",
+		Duration: 30 * 60 * 1e9,
+	}}}
+	out := loaded(f).View()
+	if !strings.Contains(out, "TIME") || !strings.Contains(out, "LOGGED") {
+		t.Errorf("view missing column headers:\n%s", out)
+	}
+	if !strings.Contains(out, "standup") {
+		t.Errorf("view missing logged label:\n%s", out)
+	}
+}
internal/tui/grid/view.go +110 −0
@@ -0,0 +1,110 @@
+package grid
+
+import (
+	"fmt"
+	"strings"
+	"time"
+
+	"github.com/charmbracelet/lipgloss"
+)
+
+var (
+	gPink   = lipgloss.Color("#D188C4")
+	gMuted  = lipgloss.Color("#878580")
+	gGreen  = lipgloss.Color("#78B892")
+	gPurple = lipgloss.Color("#5A2FE0")
+
+	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)
+)
+
+func gHeaderLine(date string) string {
+	t, err := time.ParseInLocation("2006-01-02", date, time.Local)
+	label := date
+	if err == nil {
+		label = t.Format("Monday, 02 Jan 2006")
+	}
+	return gHeader.Render("<< " + label + " >>")
+}
+
+const (
+	actWidth    = 22
+	loggedWidth = 40
+)
+
+func minuteLabel(min int) string {
+	return fmt.Sprintf("%02d:%02d", (min/60)%24, min%60)
+}
+
+func trunc(s string, w int) string {
+	r := []rune(s)
+	if len(r) <= w {
+		return s
+	}
+	if w <= 1 {
+		return string(r[:w])
+	}
+	return string(r[:w-1]) + "…"
+}
+
+func (m Model) View() string {
+	if m.quit {
+		return ""
+	}
+	if m.editing && m.form != nil {
+		hint := gDim.Render("ctrl+s save · esc cancel")
+		return gHeaderLine(m.date) + "\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")
+
+	lo, hi, selecting := m.selectionRange()
+	for row := 0; row < m.grid.Rows; row++ {
+		tcol := gDim.Render(fmt.Sprintf(" %-5s", minuteLabel(m.grid.SlotMinute(row))))
+
+		// ACTIVITY column: gap lane, band if selected
+		gap := strings.Repeat("░", actWidth)
+		if selecting && row >= lo && row <= hi {
+			gap = gBand.Render(strings.Repeat("▓", actWidth))
+		}
+
+		// LOGGED column
+		var logged string
+		if row < len(m.logged) {
+			switch m.logged[row].Kind {
+			case CellLabel:
+				logged = gLabel.Render(trunc(m.logged[row].Text, loggedWidth))
+			case CellCont:
+				logged = gDim.Render("│")
+			}
+		}
+
+		// cursor marker on the active row
+		cursorMark := " "
+		if row == m.cursor {
+			cursorMark = gCursor.Render(">")
+		}
+		b.WriteString(fmt.Sprintf("%s%s │ %s │ %s\n", cursorMark, tcol, gap, logged))
+	}
+
+	b.WriteString("\n")
+	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()
+	}
+	b.WriteString(gDim.Render(help))
+	return b.String()
+}