refactor(tui): extract shared entry-form builders into internal/tui/form
fd22a566a3bc4803d4cb14ff0c2b0096379d63e2
Kevin Kortum <kevinkortum@me.com> · 2026-07-07 21:42
parent 3a64e530
3 files changed
internal/tui/day/edit.go +7 −48
@@ -1,43 +1,22 @@
package day
import (
- "fmt"
- "strings"
- "time"
-
"github.com/charmbracelet/huh"
"ticktock/internal/store"
+ "ticktock/internal/tui/form"
)
-type formValues struct {
- Start, End, Project, Desc, Tags, Note string
-}
+// formValues aliases the shared form value type so existing day code/tests keep
+// their names.
+type formValues = form.Values
-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
-}
+// 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) }
// 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
+ return form.BuildEntry(date, orig, f)
}
// applyEdit validates and writes an edit through the store.
@@ -48,23 +27,3 @@ 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/form/form.go +78 −0
@@ -0,0 +1,78 @@
+// Package form holds the shared huh entry-form builders used by both the
+// day list view and the grid view. Pure builders only — each view keeps its
+// own edit state machine (coupled to the Bubble Tea value-copy pointer fix).
+package form
+
+import (
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/charmbracelet/huh"
+
+ "ticktock/internal/store"
+)
+
+// Values holds the raw string fields the huh form edits.
+type Values struct {
+ Start, End, Project, Desc, Tags, Note string
+}
+
+// SplitTags splits a comma-separated tag string, trimming blanks.
+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
+}
+
+// BuildEntry turns form Values into an Entry on date (HH:MM times). The
+// returned Entry has no Key; the caller decides Add vs SafeReplace.
+func BuildEntry(date string, base store.Entry, v Values) (store.Entry, error) {
+ st, err := time.ParseInLocation("2006-01-02 15:04", date+" "+v.Start, time.Local)
+ if err != nil {
+ return store.Entry{}, fmt.Errorf("start time %q: %w", v.Start, err)
+ }
+ et, err := time.ParseInLocation("2006-01-02 15:04", date+" "+v.End, time.Local)
+ if err != nil {
+ return store.Entry{}, fmt.Errorf("end time %q: %w", v.End, err)
+ }
+ return store.Entry{
+ Start: st, End: et, Project: v.Project, Description: v.Desc,
+ 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),
+ }
+}
+
+// New prefills *v from e and returns the edit form bound to v.
+func New(e store.Entry, v *Values) *huh.Form {
+ v.Start = e.Start.Format("15:04")
+ if e.Running() {
+ v.End = ""
+ } else {
+ 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)...))
+}
+
+// Blank prefills *v with start/end/project (empty desc/tags/note) for adding a
+// new entry from a grid range, and returns the form bound to v.
+func Blank(start, end, project string, v *Values) *huh.Form {
+ *v = Values{Start: start, End: end, Project: project}
+ return huh.NewForm(huh.NewGroup(inputs(v)...))
+}
internal/tui/form/form_test.go +55 −0
@@ -0,0 +1,55 @@
+package form
+
+import (
+ "testing"
+
+ "ticktock/internal/store"
+)
+
+func TestSplitTagsTrimsAndDropsEmpty(t *testing.T) {
+ got := SplitTags(" a , ,b,c ")
+ want := []string{"a", "b", "c"}
+ if len(got) != len(want) {
+ t.Fatalf("got %v want %v", got, want)
+ }
+ for i := range want {
+ if got[i] != want[i] {
+ t.Fatalf("got %v want %v", got, want)
+ }
+ }
+}
+
+func TestBuildEntryParsesTimesAndTags(t *testing.T) {
+ e, err := BuildEntry("2026-07-07", store.Entry{}, Values{
+ Start: "08:00", End: "09:30", Project: "ARCHER", Desc: "work", Tags: "a, b", Note: "n",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if e.Start.Hour() != 8 || e.End.Hour() != 9 || e.End.Minute() != 30 {
+ t.Errorf("times wrong: %v..%v", e.Start, e.End)
+ }
+ if e.Project != "ARCHER" || e.Description != "work" || e.Notes != "n" {
+ t.Errorf("fields wrong: %+v", e)
+ }
+ if len(e.Tags) != 2 || e.Tags[0] != "a" || e.Tags[1] != "b" {
+ t.Errorf("tags wrong: %v", e.Tags)
+ }
+}
+
+func TestBuildEntryRejectsBadTime(t *testing.T) {
+ if _, err := BuildEntry("2026-07-07", store.Entry{}, Values{Start: "8am", End: "09:00"}); err == nil {
+ t.Fatal("expected error on bad start time")
+ }
+}
+
+func TestBlankPrefillsStartEndProject(t *testing.T) {
+ var v Values
+ _ = Blank("10:00", "10:30", "ARCHER", &v)
+ if v.Start != "10:00" || v.End != "10:30" || v.Project != "ARCHER" {
+ t.Errorf("blank prefill wrong: %+v", v)
+ }
+ if v.Desc != "" || v.Tags != "" || v.Note != "" {
+ t.Errorf("blank should leave desc/tags/note empty: %+v", v)
+ }
+}