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