▍ humdrum codex / ticktock v0.0.2

feat(cmd): load tock history once and feed autocomplete into day + tui

9d5829b4396a732c55b1ab7eefc68fe3315f1136
Kevin Kortum <kevinkortum@me.com> · 2026-07-07 22:26

parent d713c8fc

9 files changed

README.md +4 −0
@@ -20,5 +20,9 @@   that range, `enter` edits the logged session under the cursor, `d` deletes
   it (with confirm), `n/p/t` change day, `r` reloads, `q` quits.
   Reads `~/.config/ticktock/config.json` (`slot_minutes`, `grid_start`,
   `grid_end`, `project`) if present.
+- In the add/edit form, **project**, **description**, and **tags** autocomplete
+  from your tock history (type-ahead ghost; Tab/→ to accept). Values stay free
+  text. History comes from the tock sqlite DB when that backend is active,
+  otherwise from `tock last`.
 
 Requires `tock` on PATH.
cmd/ticktock/main.go +10 −2
@@ -9,11 +9,19 @@ 	tea "github.com/charmbracelet/bubbletea"
 	"github.com/spf13/cobra"
 
 	"ticktock/internal/config"
+	"ticktock/internal/history"
 	"ticktock/internal/store"
 	"ticktock/internal/tui/day"
+	"ticktock/internal/tui/form"
 	"ticktock/internal/tui/grid"
 )
 
+// loadSuggest reads tock history once and shapes it for the form.
+func loadSuggest() form.Suggest {
+	h := history.Load()
+	return form.Suggest{Projects: h.Projects, Descriptions: h.Descriptions, Tags: h.Tags}
+}
+
 func newRootCmd() *cobra.Command {
 	root := &cobra.Command{
 		Use:   "ticktock",
@@ -34,7 +42,7 @@ 			arg := ""
 			if len(args) == 1 {
 				arg = args[0]
 			}
-			m := grid.New(store.New(store.ExecRunner{}), config.Load(), resolveDate(arg))
+			m := grid.New(store.New(store.ExecRunner{}), config.Load(), resolveDate(arg), loadSuggest())
 			_, err := tea.NewProgram(m, tea.WithAltScreen()).Run()
 			return err
 		},
@@ -51,7 +59,7 @@ 			arg := ""
 			if len(args) == 1 {
 				arg = args[0]
 			}
-			m := day.New(store.New(store.ExecRunner{}), resolveDate(arg))
+			m := day.New(store.New(store.ExecRunner{}), resolveDate(arg), loadSuggest())
 			_, err := tea.NewProgram(m, tea.WithAltScreen()).Run()
 			return err
 		},
internal/tui/day/edit.go +5 −2
@@ -11,8 +11,11 @@ // formValues aliases the shared form value type so existing day code/tests keep
 // their names.
 type formValues = form.Values
 
-// newEditForm builds the edit form bound to fv, prefilled from e.
-func newEditForm(e store.Entry, fv *formValues) *huh.Form { return form.New(e, fv) }
+// newEditForm builds the edit form bound to fv, prefilled from e, with history
+// suggestions attached.
+func newEditForm(e store.Entry, fv *formValues, sug form.Suggest) *huh.Form {
+	return form.New(e, fv, sug)
+}
 
 // buildUpdated turns edited form values into a new Entry on the given date.
 func buildUpdated(date string, orig store.Entry, f formValues) (store.Entry, error) {
internal/tui/day/model.go +5 −3
@@ -9,6 +9,7 @@ 	tea "github.com/charmbracelet/bubbletea"
 	"github.com/charmbracelet/huh"
 
 	"ticktock/internal/store"
+	"ticktock/internal/tui/form"
 )
 
 // Model is the day-view Bubble Tea model.
@@ -29,6 +30,7 @@ 	fv       *formValues
 	editKey  string
 	editOrig store.Entry
 	status   string
+	sug      form.Suggest
 }
 
 type entriesMsg struct {
@@ -37,7 +39,7 @@ 	err     error
 }
 
 // New builds a day model for the given date (YYYY-MM-DD).
-func New(s store.Store, date string) Model {
+func New(s store.Store, date string, sug form.Suggest) Model {
 	cols := []table.Column{
 		{Title: "Key", Width: 13},
 		{Title: "Time", Width: 15},
@@ -49,7 +51,7 @@ 		{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}
+	return Model{store: s, date: date, tbl: t, sug: sug}
 }
 
 func (m Model) Init() tea.Cmd { return m.load() }
@@ -115,7 +117,7 @@ 				m.editing = true
 				m.editKey = e.Key
 				m.editOrig = e
 				m.fv = &formValues{}
-				m.form = newEditForm(e, m.fv)
+				m.form = newEditForm(e, m.fv, m.sug)
 				return m, m.form.Init()
 			}
 			return m, nil
internal/tui/day/model_test.go +5 −4
@@ -9,6 +9,7 @@ 	tea "github.com/charmbracelet/bubbletea"
 	"github.com/charmbracelet/x/exp/teatest"
 
 	"ticktock/internal/store"
+	"ticktock/internal/tui/form"
 )
 
 type fakeStore struct {
@@ -91,7 +92,7 @@ }
 
 func TestDayRendersTableAndTotals(t *testing.T) {
 	fs := &fakeStore{days: map[string][]store.Entry{"2026-07-07": sample()}}
-	tm := teatest.NewTestModel(t, New(fs, "2026-07-07"),
+	tm := teatest.NewTestModel(t, New(fs, "2026-07-07", form.Suggest{}),
 		teatest.WithInitialTermSize(140, 24))
 	teatest.WaitFor(t, tm.Output(), func(b []byte) bool {
 		return bytes.Contains(b, []byte("ARCHER")) &&
@@ -134,7 +135,7 @@
 // loaded builds a Model with entries already loaded and the cursor at row i.
 func loaded(entries []store.Entry, i int) Model {
 	fs := &fakeStore{days: map[string][]store.Entry{"2026-07-07": entries}}
-	m := New(fs, "2026-07-07")
+	m := New(fs, "2026-07-07", form.Suggest{})
 	tm, _ := m.Update(entriesMsg{entries: entries})
 	m = tm.(Model)
 	m.tbl.SetCursor(i)
@@ -194,7 +195,7 @@ // SafeReplace. Guards the value-receiver copy bug where huh wrote edits into a
 // stale Model copy and applyEdit re-saved the unchanged original.
 func TestEditFieldReachesStore(t *testing.T) {
 	fs := &fakeStore{days: map[string][]store.Entry{"2026-07-07": sample()}}
-	tm := teatest.NewTestModel(t, New(fs, "2026-07-07"),
+	tm := teatest.NewTestModel(t, New(fs, "2026-07-07", form.Suggest{}),
 		teatest.WithInitialTermSize(140, 24))
 
 	// Wait for the table, then open the editor on row 0 (2026-07-07-01).
@@ -239,7 +240,7 @@ // TestCtrlSSubmitsFromAnyField retypes the start field then submits with Ctrl+S
 // WITHOUT tabbing to the last field, and asserts the typed value is stored.
 func TestCtrlSSubmitsFromAnyField(t *testing.T) {
 	fs := &fakeStore{days: map[string][]store.Entry{"2026-07-07": sample()}}
-	tm := teatest.NewTestModel(t, New(fs, "2026-07-07"),
+	tm := teatest.NewTestModel(t, New(fs, "2026-07-07", form.Suggest{}),
 		teatest.WithInitialTermSize(140, 24))
 	teatest.WaitFor(t, tm.Output(), func(b []byte) bool {
 		return bytes.Contains(b, []byte("ARCHER"))
internal/tui/form/form.go +18 −16
@@ -46,19 +46,14 @@ 		Tags: SplitTags(v.Tags), Notes: v.Note,
 	}, nil
 }
 
-func inputs(v *Values) []huh.Field {
-	return []huh.Field{
-		huh.NewInput().Title("start (HH:MM)").Value(&v.Start),
-		huh.NewInput().Title("end (HH:MM)").Value(&v.End),
-		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),
-	}
+// Suggest carries autocomplete candidates for the form's inputs.
+type Suggest struct {
+	Projects, Descriptions, Tags []string
 }
 
-// New prefills *v from e and returns the edit form bound to v.
-func New(e store.Entry, v *Values) *huh.Form {
+// New prefills *v from e and returns the edit form bound to v, with history
+// suggestions attached to project/description/tags.
+func New(e store.Entry, v *Values, sug Suggest) *huh.Form {
 	v.Start = e.Start.Format("15:04")
 	if e.Running() {
 		v.End = ""
@@ -67,18 +62,25 @@ 		v.End = e.End.Format("15:04")
 	}
 	v.Project, v.Desc = e.Project, e.Description
 	v.Tags, v.Note = strings.Join(e.Tags, ", "), e.Notes
-	return huh.NewForm(huh.NewGroup(inputs(v)...))
+	return huh.NewForm(huh.NewGroup(
+		huh.NewInput().Title("start (HH:MM)").Value(&v.Start),
+		huh.NewInput().Title("end (HH:MM)").Value(&v.End),
+		huh.NewInput().Title("project").Value(&v.Project).Suggestions(sug.Projects),
+		huh.NewInput().Title("description").Value(&v.Desc).Suggestions(sug.Descriptions),
+		huh.NewInput().Title("tags (comma-sep)").Value(&v.Tags).Suggestions(sug.Tags),
+		huh.NewInput().Title("note").Value(&v.Note),
+	))
 }
 
 // AddForm builds a form for a new entry whose start/end are already fixed by a
 // grid selection. Start/end are prefilled and placed last so the cursor lands
 // on project, yet the times remain editable if the selection needs tweaking.
-func AddForm(start, end, project string, v *Values) *huh.Form {
+func AddForm(start, end, project string, v *Values, sug Suggest) *huh.Form {
 	*v = Values{Start: start, End: end, Project: project}
 	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("project").Value(&v.Project).Suggestions(sug.Projects),
+		huh.NewInput().Title("description").Value(&v.Desc).Suggestions(sug.Descriptions),
+		huh.NewInput().Title("tags (comma-sep)").Value(&v.Tags).Suggestions(sug.Tags),
 		huh.NewInput().Title("note").Value(&v.Note),
 		huh.NewInput().Title("start (HH:MM)").Value(&v.Start),
 		huh.NewInput().Title("end (HH:MM)").Value(&v.End),
internal/tui/form/form_test.go +1 −1
@@ -45,7 +45,7 @@ }
 
 func TestAddFormPrefillsStartEndProject(t *testing.T) {
 	var v Values
-	_ = AddForm("10:00", "10:30", "ARCHER", &v)
+	_ = AddForm("10:00", "10:30", "ARCHER", &v, Suggest{})
 	if v.Start != "10:00" || v.End != "10:30" || v.Project != "ARCHER" {
 		t.Errorf("add-form prefill wrong: %+v", v)
 	}
internal/tui/grid/model.go +6 −4
@@ -47,6 +47,8 @@
 	confirmDelete bool
 	confirmKey    string
 
+	sug form.Suggest
+
 	status string
 	err    error
 	quit   bool
@@ -58,8 +60,8 @@ 	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 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() }
@@ -216,7 +218,7 @@ 	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.form = form.AddForm(start, end, m.cfg.Project, m.fv, m.sug)
 	return m, m.form.Init()
 }
 
@@ -253,7 +255,7 @@ 	}
 	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.form = form.New(e, m.fv, m.sug)
 	return m, m.form.Init()
 }
 
internal/tui/grid/model_test.go +2 −1
@@ -9,6 +9,7 @@ 	tea "github.com/charmbracelet/bubbletea"
 
 	"ticktock/internal/config"
 	"ticktock/internal/store"
+	"ticktock/internal/tui/form"
 )
 
 type fakeStore struct {
@@ -35,7 +36,7 @@ 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")
+	m := New(f, cfg(), "2026-07-07", form.Suggest{})
 	nm, _ := m.Update(entriesMsg{entries: f.entries})
 	return nm.(Model)
 }