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 sug form.Suggest 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, sug form.Suggest) Model { return Model{store: s, cfg: cfg, date: date, col: ColActivity, sug: sug} } 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 "p": m.date = shiftDate(m.date, -1) m.anchor = nil return m, m.load() case "n": 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.AddForm(start, end, m.cfg.Project, m.fv, m.sug) 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, m.sug) 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() }