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