package day import ( "strings" "time" "github.com/charmbracelet/bubbles/table" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/huh" "ticktock/internal/store" "ticktock/internal/tui/form" ) // Model is the day-view Bubble Tea model. type Model struct { store store.Store date string entries []store.Entry tbl table.Model err error quit bool editing bool form *huh.Form // fv is heap-allocated so the huh form and applyEdit share ONE instance. // Bubble Tea passes Model by value into every Update, so a value field here // would let huh write typed edits into a stale copy that applyEdit never sees. fv *formValues editKey string editOrig store.Entry status string sug form.Suggest } type entriesMsg struct { entries []store.Entry err error } // New builds a day model for the given date (YYYY-MM-DD). func New(s store.Store, date string, sug form.Suggest) Model { cols := []table.Column{ {Title: "Key", Width: 13}, {Title: "Time", Width: 15}, {Title: "Project", Width: 10}, {Title: "Description", Width: 30}, {Title: "Duration", Width: 10}, {Title: "Tags", Width: 14}, {Title: "Notes", Width: 30}, } t := table.New(table.WithColumns(cols), table.WithFocused(true), table.WithHeight(14)) t.SetStyles(tableStyles()) return Model{store: s, date: date, tbl: t, 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} } } func (m *Model) setRows() { rows := make([]table.Row, 0, len(m.entries)) for _, e := range m.entries { rows = append(rows, table.Row{ e.Key, fmtRange(e), e.Project, e.Description, fmtTockDur(e.Duration), strings.Join(e.Tags, ","), e.Notes, }) } m.tbl.SetRows(rows) } func (m Model) selected() (store.Entry, bool) { i := m.tbl.Cursor() if i < 0 || i >= len(m.entries) { return store.Entry{}, false } return m.entries[i], 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.editing { return m.updateEditing(msg) } switch msg := msg.(type) { case entriesMsg: m.entries, m.err = msg.entries, msg.err m.setRows() return m, nil case tea.KeyMsg: switch msg.String() { case "q", "ctrl+c": m.quit = true return m, tea.Quit case "left": m.date = shiftDate(m.date, -1) return m, m.load() case "right": m.date = shiftDate(m.date, 1) return m, m.load() case "enter": if e, ok := m.selected(); ok && !e.Running() { m.editing = true m.editKey = e.Key m.editOrig = e m.fv = &formValues{} m.form = newEditForm(e, m.fv, m.sug) return m, m.form.Init() } return m, nil } } var cmd tea.Cmd m.tbl, cmd = m.tbl.Update(msg) return m, cmd } 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 = false m.form = nil return m, nil case "ctrl+s": // Submit from any field. huh writes each keystroke live into the // shared *fv, so current values are already captured. return m.finishEdit() } } f, cmd := m.form.Update(msg) if ff, ok := f.(*huh.Form); ok { m.form = ff } if m.form.State == huh.StateCompleted { return m.finishEdit() } return m, cmd } // finishEdit writes the current form values through the store and returns to the // table. Shared by Ctrl+S (submit-from-anywhere) and huh's own completion. func (m Model) finishEdit() (tea.Model, tea.Cmd) { m.editing = false if err := applyEdit(m.store, m.date, m.editOrig, *m.fv); err != nil { m.status = "edit failed: " + err.Error() } else { m.status = "updated " + m.editKey } m.form = nil return m, m.load() }