▍ humdrum codex / ticktock v0.0.2

feat(day): inline edit via huh form, wired to ticktock day

ebb9a946eb2f23261d8292562e92c869202b8147
humdrum-tiv <45084903+humdrum-tiv@users.noreply.github.com> · 2026-07-07 16:55

parent b4a03107

7 files changed

cmd/ticktock/main.go +24 −2
@@ -3,8 +3,13 @@
 import (
 	"fmt"
 	"os"
+	"time"
 
+	tea "github.com/charmbracelet/bubbletea"
 	"github.com/spf13/cobra"
+
+	"ticktock/internal/store"
+	"ticktock/internal/tui/day"
 )
 
 func newRootCmd() *cobra.Command {
@@ -16,15 +21,32 @@ 	root.AddCommand(newDayCmd())
 	return root
 }
 
-// newDayCmd is stubbed here and wired to the TUI in Task 5.
 func newDayCmd() *cobra.Command {
 	return &cobra.Command{
 		Use:   "day [date|today|yesterday]",
 		Short: "Review and edit a day's tock entries",
 		Args:  cobra.MaximumNArgs(1),
 		RunE: func(cmd *cobra.Command, args []string) error {
-			return fmt.Errorf("day: not wired yet")
+			arg := ""
+			if len(args) == 1 {
+				arg = args[0]
+			}
+			m := day.New(store.New(store.ExecRunner{}), resolveDate(arg))
+			_, err := tea.NewProgram(m, tea.WithAltScreen()).Run()
+			return err
 		},
+	}
+}
+
+func resolveDate(arg string) string {
+	now := time.Now()
+	switch arg {
+	case "", "today":
+		return now.Format("2006-01-02")
+	case "yesterday":
+		return now.AddDate(0, 0, -1).Format("2006-01-02")
+	default:
+		return arg
 	}
 }
 
go.mod +6 −0
@@ -5,28 +5,34 @@
 require (
 	github.com/charmbracelet/bubbles v1.0.0
 	github.com/charmbracelet/bubbletea v1.3.10
+	github.com/charmbracelet/huh v1.0.0
 	github.com/charmbracelet/lipgloss v1.1.0
 	github.com/charmbracelet/x/exp/teatest v0.0.0-20260705004817-2cc9a8fe1146
 	github.com/spf13/cobra v1.10.2
 )
 
 require (
+	github.com/atotto/clipboard v0.1.4 // indirect
 	github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
 	github.com/aymanbagabas/go-udiff v0.3.1 // indirect
+	github.com/catppuccin/go v0.3.0 // indirect
 	github.com/charmbracelet/colorprofile v0.4.1 // indirect
 	github.com/charmbracelet/x/ansi v0.11.6 // indirect
 	github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
 	github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 // indirect
+	github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
 	github.com/charmbracelet/x/term v0.2.2 // indirect
 	github.com/clipperhouse/displaywidth v0.9.0 // indirect
 	github.com/clipperhouse/stringish v0.1.1 // indirect
 	github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
+	github.com/dustin/go-humanize v1.0.1 // indirect
 	github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
 	github.com/inconshreveable/mousetrap v1.1.0 // indirect
 	github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
 	github.com/mattn/go-isatty v0.0.20 // indirect
 	github.com/mattn/go-localereader v0.0.1 // indirect
 	github.com/mattn/go-runewidth v0.0.19 // indirect
+	github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
 	github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
 	github.com/muesli/cancelreader v0.2.2 // indirect
 	github.com/muesli/termenv v0.16.0 // indirect
go.sum +24 −0
@@ -1,25 +1,43 @@
+github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
+github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
+github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
+github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
 github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
 github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
 github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
 github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
+github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
+github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
 github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
 github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
 github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
 github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
 github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
 github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
+github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw=
+github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
 github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
 github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
 github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
 github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
 github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
 github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
+github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
+github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=
+github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
+github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
 github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
 github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
+github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
+github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
 github.com/charmbracelet/x/exp/teatest v0.0.0-20260705004817-2cc9a8fe1146 h1:nqRPYV88DBRdlHx+yhMK15UrSMfdVTHubb3jE90HdIU=
 github.com/charmbracelet/x/exp/teatest v0.0.0-20260705004817-2cc9a8fe1146/go.mod h1:aPVjFrBwbJgj5Qz1F0IXsnbcOVJcMKgu1ySUfTAxh7k=
 github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
 github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
+github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
+github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
+github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI=
+github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4=
 github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
 github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
 github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
@@ -27,6 +45,10 @@ github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
 github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
 github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
 github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
+github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
+github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
 github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
@@ -39,6 +61,8 @@ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
 github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
 github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
 github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
+github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
+github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
 github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
internal/tui/day/edit.go +70 −0
@@ -0,0 +1,70 @@
+package day
+
+import (
+	"fmt"
+	"strings"
+	"time"
+
+	"github.com/charmbracelet/huh"
+
+	"ticktock/internal/store"
+)
+
+type formValues struct {
+	Start, End, Project, Desc, Tags, Note string
+}
+
+func splitTags(s string) []string {
+	var out []string
+	for _, p := range strings.Split(s, ",") {
+		if p = strings.TrimSpace(p); p != "" {
+			out = append(out, p)
+		}
+	}
+	return out
+}
+
+// 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) {
+	st, err := time.ParseInLocation("2006-01-02 15:04", date+" "+f.Start, time.Local)
+	if err != nil {
+		return store.Entry{}, fmt.Errorf("start time %q: %w", f.Start, err)
+	}
+	et, err := time.ParseInLocation("2006-01-02 15:04", date+" "+f.End, time.Local)
+	if err != nil {
+		return store.Entry{}, fmt.Errorf("end time %q: %w", f.End, err)
+	}
+	return store.Entry{
+		Start: st, End: et, Project: f.Project, Description: f.Desc,
+		Tags: splitTags(f.Tags), Notes: f.Note,
+	}, nil
+}
+
+// applyEdit validates and writes an edit through the store.
+func applyEdit(s store.Store, date string, orig store.Entry, f formValues) error {
+	up, err := buildUpdated(date, orig, f)
+	if err != nil {
+		return err
+	}
+	return s.SafeReplace(orig.Key, up, orig)
+}
+
+// newEditForm builds a huh form bound to fv, prefilled from e.
+func newEditForm(e store.Entry, fv *formValues) *huh.Form {
+	fv.Start = e.Start.Format("15:04")
+	if e.Running() {
+		fv.End = ""
+	} else {
+		fv.End = e.End.Format("15:04")
+	}
+	fv.Project, fv.Desc = e.Project, e.Description
+	fv.Tags, fv.Note = strings.Join(e.Tags, ", "), e.Notes
+	return huh.NewForm(huh.NewGroup(
+		huh.NewInput().Title("start (HH:MM)").Value(&fv.Start),
+		huh.NewInput().Title("end (HH:MM)").Value(&fv.End),
+		huh.NewInput().Title("project").Value(&fv.Project),
+		huh.NewInput().Title("description").Value(&fv.Desc),
+		huh.NewInput().Title("tags (comma-sep)").Value(&fv.Tags),
+		huh.NewInput().Title("note").Value(&fv.Note),
+	))
+}
internal/tui/day/model.go +43 −0
@@ -6,6 +6,7 @@ 	"time"
 
 	"github.com/charmbracelet/bubbles/table"
 	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/huh"
 
 	"ticktock/internal/store"
 )
@@ -18,6 +19,13 @@ 	entries []store.Entry
 	tbl     table.Model
 	err     error
 	quit    bool
+
+	editing  bool
+	form     *huh.Form
+	fv       formValues
+	editKey  string
+	editOrig store.Entry
+	status   string
 }
 
 type entriesMsg struct {
@@ -79,6 +87,9 @@ 	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
@@ -95,9 +106,41 @@ 			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.form = newEditForm(e, &m.fv)
+				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 && km.String() == "esc" {
+		m.editing = false
+		m.form = nil
+		return m, nil
+	}
+	f, cmd := m.form.Update(msg)
+	if ff, ok := f.(*huh.Form); ok {
+		m.form = ff
+	}
+	if m.form.State == huh.StateCompleted {
+		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()
+	}
+	return m, cmd
+}
internal/tui/day/model_test.go +29 −0
@@ -69,3 +69,32 @@ 	}, teatest.WithDuration(2*time.Second))
 	tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}})
 	tm.WaitFinished(t, teatest.WithFinalTimeout(2*time.Second))
 }
+
+func TestApplyEditCallsSafeReplaceWithKey(t *testing.T) {
+	fs := &fakeStore{days: map[string][]store.Entry{}}
+	orig := sample()[0] // Key 2026-07-07-01
+	fv := formValues{Start: "09:05", End: "09:55", Project: "ARCHER",
+		Desc: "Admin: done", Tags: "emails, sorted", Note: "inbox zero"}
+	if err := applyEdit(fs, "2026-07-07", orig, fv); err != nil {
+		t.Fatal(err)
+	}
+	if len(fs.replaced) != 1 || fs.replaced[0] != "2026-07-07-01" {
+		t.Fatalf("SafeReplace keys = %v, want [2026-07-07-01]", fs.replaced)
+	}
+}
+
+func TestBuildUpdatedParsesTimesAndTags(t *testing.T) {
+	orig := sample()[0]
+	up, err := buildUpdated("2026-07-07", orig,
+		formValues{Start: "09:05", End: "09:55", Project: "ARCHER",
+			Desc: "x", Tags: "a, b ,", Note: "n"})
+	if err != nil {
+		t.Fatal(err)
+	}
+	if up.Start.Format("15:04") != "09:05" || up.End.Format("15:04") != "09:55" {
+		t.Errorf("times not parsed: %v–%v", up.Start, up.End)
+	}
+	if len(up.Tags) != 2 || up.Tags[0] != "a" || up.Tags[1] != "b" {
+		t.Errorf("tags = %v, want [a b]", up.Tags)
+	}
+}
internal/tui/day/view.go +6 −0
@@ -51,11 +51,17 @@ func (m Model) View() string {
 	if m.quit {
 		return ""
 	}
+	if m.editing && m.form != nil {
+		return headerLine(m.date) + "\n\n" + m.form.View()
+	}
 	body := m.tbl.View()
 	if len(m.entries) == 0 {
 		body = footerStyle.Render("  no entries")
 	}
 	help := footerStyle.Render("enter edit · ←/→ date · q quit")
+	if m.status != "" {
+		help = footerStyle.Render(m.status)
+	}
 	if m.err != nil {
 		help = footerStyle.Render("error: " + m.err.Error())
 	}