▍ humdrum codex / ticktock v0.0.2

fix(day): edits didn't persist — heap-share formValues across Bubble Tea copies

b9c9b9766a23c18b35096cb8ed1bf27d176c937a
humdrum-tiv <45084903+humdrum-tiv@users.noreply.github.com> · 2026-07-07 17:50

parent b1293a92

fix(day): edits didn't persist — heap-share formValues across Bubble Tea copies

Bubble Tea passes Model by value into every Update, so binding the huh form to
a value-field &m.fv let huh write typed edits into a stale copy; applyEdit then
re-saved the unchanged original. Make fv a *formValues so the form and applyEdit
share one instance. Regression test drives the real loop end-to-end.

2 files changed

internal/tui/day/model.go +9 −5
@@ -20,9 +20,12 @@ 	tbl     table.Model
 	err     error
 	quit    bool
 
-	editing  bool
-	form     *huh.Form
-	fv       formValues
+	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
@@ -111,7 +114,8 @@ 			if e, ok := m.selected(); ok && !e.Running() {
 				m.editing = true
 				m.editKey = e.Key
 				m.editOrig = e
-				m.form = newEditForm(e, &m.fv)
+				m.fv = &formValues{}
+				m.form = newEditForm(e, m.fv)
 				return m, m.form.Init()
 			}
 			return m, nil
@@ -134,7 +138,7 @@ 		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 {
+		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
internal/tui/day/model_test.go +51 −2
@@ -13,14 +13,16 @@ )
 
 type fakeStore struct {
 	days     map[string][]store.Entry
-	replaced []string // keys passed to SafeReplace
+	replaced []string      // keys passed to SafeReplace
+	updated  []store.Entry // updated entry passed to SafeReplace
 }
 
 func (f *fakeStore) Day(date string) ([]store.Entry, error) { return f.days[date], nil }
 func (f *fakeStore) Add(store.Entry) error                  { return nil }
 func (f *fakeStore) Remove(string) error                    { return nil }
-func (f *fakeStore) SafeReplace(key string, _, _ store.Entry) error {
+func (f *fakeStore) SafeReplace(key string, updated, _ store.Entry) error {
 	f.replaced = append(f.replaced, key)
+	f.updated = append(f.updated, updated)
 	return nil
 }
 
@@ -183,6 +185,53 @@ 	}
 	tm, _ = m.Update(tea.KeyMsg{Type: tea.KeyRight})
 	if got := tm.(Model).date; got != "2026-07-07" {
 		t.Errorf("right => date %q, want 2026-07-07", got)
+	}
+}
+
+// TestEditFieldReachesStore drives the real Bubble Tea loop: open the editor,
+// retype the start field, submit, and assert the *typed* value reaches
+// 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"),
+		teatest.WithInitialTermSize(140, 24))
+
+	// Wait for the table, then open the editor on row 0 (2026-07-07-01).
+	teatest.WaitFor(t, tm.Output(), func(b []byte) bool {
+		return bytes.Contains(b, []byte("ARCHER"))
+	}, teatest.WithDuration(2*time.Second))
+	tm.Send(tea.KeyMsg{Type: tea.KeyEnter})
+
+	// Wait for the huh form (its field titles render).
+	teatest.WaitFor(t, tm.Output(), func(b []byte) bool {
+		return bytes.Contains(b, []byte("start (HH:MM)"))
+	}, teatest.WithDuration(2*time.Second))
+
+	// Start is prefilled "09:01"; clear it and type "08:00".
+	for i := 0; i < 6; i++ {
+		tm.Send(tea.KeyMsg{Type: tea.KeyBackspace})
+	}
+	for _, r := range "08:00" {
+		tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
+	}
+	// Advance through the 6 fields and submit (enter completes on the last).
+	for i := 0; i < 6; i++ {
+		tm.Send(tea.KeyMsg{Type: tea.KeyEnter})
+	}
+
+	teatest.WaitFor(t, tm.Output(), func(b []byte) bool {
+		return bytes.Contains(b, []byte("updated 2026-07-07-01"))
+	}, teatest.WithDuration(2*time.Second))
+
+	tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}})
+	tm.WaitFinished(t, teatest.WithFinalTimeout(2*time.Second))
+
+	if len(fs.updated) != 1 {
+		t.Fatalf("SafeReplace calls = %d, want 1", len(fs.updated))
+	}
+	if got := fs.updated[0].Start.Format("15:04"); got != "08:00" {
+		t.Errorf("stored start = %q, want 08:00 (typed edit did not reach the store)", got)
 	}
 }