diff --git a/lib/ui/input_widgets.go b/lib/ui/input_widgets.go index 3196c8e7..98848ad7 100644 --- a/lib/ui/input_widgets.go +++ b/lib/ui/input_widgets.go @@ -1,6 +1,7 @@ package ui import ( + "errors" "html/template" "io" "strconv" @@ -178,9 +179,10 @@ func (u *InputDate) JawsUpdate(elem *jaws.Element) { // JawsInput stores a browser-side date input value. // -// The browser sends a calendar date (YYYY-MM-DD), which [time.Parse] resolves -// to midnight UTC, so the stored [time.Time] drops any time-of-day and -// [time.Location] the previously bound value carried. In a non-UTC deployment +// An empty value maps to the zero [time.Time], which renders as "0001-01-01". +// Non-empty values are calendar dates (YYYY-MM-DD). [time.Parse] +// resolves them to midnight UTC, so the stored [time.Time] drops any time-of-day +// and [time.Location] the previously bound value carried. In a non-UTC deployment // the stored instant therefore shifts by the zone offset, and because // [time.Time] inequality includes the location, re-selecting the same date // still reports a change and broadcasts it. Bind a date whose clock and zone are @@ -193,6 +195,7 @@ func (u *InputDate) JawsUpdate(elem *jaws.Element) { // parse error and leaves the last accepted value in place instead of updating the // bound value. Keep bound years within 1..9999. func (u *InputDate) JawsInput(elem *jaws.Element, value string) (err error) { + input := value if value == "" { value = "0001-01-01" } @@ -200,8 +203,14 @@ func (u *InputDate) JawsInput(elem *jaws.Element, value string) (err error) { // Parse errors are malformed client frames: jaws.js reads elem.value from // browser date controls. Leave Last as the last accepted value. if v, err = time.Parse(assets.ISO8601, value); err == nil { - u.Last.Store(u.str(v)) - err = u.maybeDirty(elem, u.Setter.JawsSet(elem, v)) + u.Last.Store(input) + err = u.Setter.JawsSet(elem, v) + if input == "" && u.tag != nil && errors.Is(err, jaws.ErrValueUnchanged) { + elem.Dirty(elem) + err = nil + return + } + err = u.maybeDirty(elem, err) } return } diff --git a/lib/ui/input_widgets_test.go b/lib/ui/input_widgets_test.go index 8e7b8b9f..18970d70 100644 --- a/lib/ui/input_widgets_test.go +++ b/lib/ui/input_widgets_test.go @@ -5,6 +5,7 @@ import ( "html/template" "strings" "testing" + "testing/synctest" "time" "github.com/linkdata/deadlock" @@ -176,6 +177,74 @@ func TestInputDateWidget(t *testing.T) { date.JawsUpdate(elem) } +func TestInputDate_ClearReconcilesCanonicalZero(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + go jw.Serve() + tr := jawstest.NewTestRequest(jw, nil) + <-tr.ReadyCh + defer func() { + tr.Close() + jw.Close() + synctest.Wait() + }() + + source := newTestSetter(time.Date(2026, time.August, 15, 0, 0, 0, 0, time.UTC)) + originElem, _ := renderUI(t, tr.Request, NewDate(source)) + peerElem, _ := renderUI(t, tr.Request, NewDate(source)) + + waitForUpdate := func() { + synctest.Wait() + time.Sleep(jaws.DefaultUpdateInterval + time.Millisecond) + synctest.Wait() + } + readCorrection := func() (msg wire.WsMsg) { + t.Helper() + select { + case msg = <-tr.OutCh: + if msg.What != what.Value || msg.Data != "0001-01-01" { + t.Fatalf("date correction = %#v, want Value %q", msg, "0001-01-01") + } + default: + t.Fatal("missing date correction") + } + return + } + + tr.InCh <- wire.WsMsg{Jid: originElem.Jid(), What: what.Input, Data: ""} + waitForUpdate() + if got := source.Get(); !got.IsZero() { + t.Fatalf("cleared date = %v, want zero time", got) + } + corrections := map[jaws.Jid]bool{ + readCorrection().Jid: true, + readCorrection().Jid: true, + } + if len(corrections) != 2 || !corrections[originElem.Jid()] || !corrections[peerElem.Jid()] { + t.Fatalf("corrected elements = %v, want origin and peer", corrections) + } + select { + case msg := <-tr.OutCh: + t.Fatalf("unexpected additional date output: %#v", msg) + default: + } + + tr.InCh <- wire.WsMsg{Jid: originElem.Jid(), What: what.Input, Data: ""} + waitForUpdate() + if msg := readCorrection(); msg.Jid != originElem.Jid() { + t.Fatalf("unchanged correction Jid = %v, want %v", msg.Jid, originElem.Jid()) + } + select { + case msg := <-tr.OutCh: + t.Fatalf("unexpected additional date output: %#v", msg) + default: + } + }) +} + // TestInputDate_BrowserEditNormalizesToMidnightUTC locks in the documented // date-only behavior (issue #124): the control renders/reads a calendar date, so // a browser edit resolves through time.Parse to midnight UTC and drops the bound