From 07f7550931f064281e6b401a0cd6cc65e3e548e4 Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 6 Aug 2026 04:23:46 +0400 Subject: [PATCH] /events and /morning read the clock on his wall (V-612) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three defects on the server-rendered pages. /events printed both timestamps in whatever zone the value arrived in. NoticedAt is the bus's local instant; OccurredAt is the store's UTC, or a pubDate internal/rss parsed to UTC. So one row carried two zones and a feed item read hours older than it was, on a page whose hint tells him that column gap is real. /morning printed a plan item's At raw. It is a calendar fact's Ts or a reminder's FireTs, both UTC out of the store, so the same reminder named a different hour here than on /reminders — which does call Local, since V-469. promoteCandidate read the importance select inside `if due != nil`. Confirming a candidate as "срочно" with no deadline threw the word away and the row came back normal with nothing saying why. Co-Authored-By: Claude Opus 5 --- cmd/mavweb/events.html | 9 +++-- cmd/mavweb/events_test.go | 35 +++++++++++++++++++- cmd/mavweb/morning.html | 5 ++- cmd/mavweb/morning_test.go | 49 +++++++++++++++++++++++++++ cmd/mavweb/tasks.go | 14 +++++--- cmd/mavweb/tasks_test.go | 68 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 171 insertions(+), 9 deletions(-) create mode 100644 cmd/mavweb/morning_test.go diff --git a/cmd/mavweb/events.html b/cmd/mavweb/events.html index a344c98..d1acafe 100644 --- a/cmd/mavweb/events.html +++ b/cmd/mavweb/events.html @@ -14,8 +14,13 @@ memory only, so a restart empties this.
{{range .Events}} - - + + + diff --git a/cmd/mavweb/events_test.go b/cmd/mavweb/events_test.go index 2d86102..0e280b2 100644 --- a/cmd/mavweb/events_test.go +++ b/cmd/mavweb/events_test.go @@ -46,7 +46,8 @@ func TestEventsPageRendersTheJournal(t *testing.T) { t.Fatalf("status = %d, want 200", w.Code) } body := w.Body.String() - for _, want := range []string{"rss:tech", "Вышло ядро 6.19", "ambient:notif", "10:00-11:00 планёрка", "01.08 10:00:00"} { + occurred := time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC).Local().Format("02.01 15:04:05") + for _, want := range []string{"rss:tech", "Вышло ядро 6.19", "ambient:notif", "10:00-11:00 планёрка", occurred} { if !strings.Contains(body, want) { t.Errorf("page does not mention %q", want) } @@ -89,6 +90,38 @@ func TestEventsPageWithoutCore(t *testing.T) { } } +// awayFromLocal returns a zone three hours off whatever this machine runs in, +// so a test can tell "rendered in his clock" apart from "rendered in whatever +// zone the value arrived in" without depending on TZ. +func awayFromLocal() *time.Location { + _, off := time.Now().Zone() + return time.FixedZone("away", off+3*60*60) +} + +func TestEventsPageRendersBothTimesInLocalZone(t *testing.T) { + // OccurredAt carries the source's zone — the store hands back UTC and + // internal/rss parses a pubDate to UTC — while NoticedAt is the bus's local + // instant. Rendered raw, the two columns of one row were in two zones and a + // feed item read hours older than it was. + away := awayFromLocal() + occurred := time.Date(2026, 8, 1, 7, 15, 0, 0, time.UTC).In(away) + noticed := occurred.Add(2 * time.Minute) + core := &eventsCore{events: []ipc.IntakeEvent{{ + Source: "rss:tech", Kind: "note", Title: "Вышло ядро 6.19", Priority: "low", + OccurredAt: occurred, NoticedAt: noticed, + }}} + body := getEvents(t, core).Body.String() + const layout = "02.01 15:04:05" + for _, ts := range []time.Time{occurred, noticed} { + if !strings.Contains(body, ts.Local().Format(layout)) { + t.Errorf("page does not render %s in his clock (%s)", ts, ts.Local().Format(layout)) + } + if strings.Contains(body, ts.In(away).Format(layout)) { + t.Errorf("page rendered %s in the source's zone", ts) + } + } +} + func TestEventsPageEscapesIntakeText(t *testing.T) { // Titles come from outside — a feed headline, a notification. They are shown // on a page and must never be able to inject markup into it. diff --git a/cmd/mavweb/morning.html b/cmd/mavweb/morning.html index 3db912e..3de5827 100644 --- a/cmd/mavweb/morning.html +++ b/cmd/mavweb/morning.html @@ -8,7 +8,10 @@
noticedhappenedsourcekindpriwhatdetail
{{.NoticedAt.Format "02.01 15:04:05"}}{{.OccurredAt.Format "02.01 15:04:05"}}{{.NoticedAt.Local.Format "02.01 15:04:05"}}{{.OccurredAt.Local.Format "02.01 15:04:05"}} {{.Source}} {{.Kind}} {{.Priority}}
{{range .Items}} - + +{{end}} diff --git a/cmd/mavweb/morning_test.go b/cmd/mavweb/morning_test.go new file mode 100644 index 0000000..980b4e6 --- /dev/null +++ b/cmd/mavweb/morning_test.go @@ -0,0 +1,49 @@ +package main + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/kami/maven/internal/ipc" +) + +// morningCore serves a canned checklist and day plan. +type morningCore struct { + ipc.UnimplementedCoreAPI + status []ipc.MorningRoutineStatus + plan ipc.DayPlan +} + +func (c *morningCore) MorningStatus(context.Context) ([]ipc.MorningRoutineStatus, error) { + return c.status, nil +} + +func (c *morningCore) DayPlan(context.Context) (ipc.DayPlan, error) { return c.plan, nil } + +func TestMorningRendersPlanTimesInLocalZone(t *testing.T) { + // A plan item's At is a calendar fact's Ts or a reminder's FireTs, and the + // store hands both back as UTC. Printed raw, /morning named an hour for a + // reminder that /reminders — which does call Local — disagreed with. + away := awayFromLocal() + at := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC).In(away) + core := &morningCore{plan: ipc.DayPlan{ + Date: at, + Items: []ipc.DayPlanItem{{At: at, Text: "выпить таблетки", Kind: "reminder"}}, + }} + w := httptest.NewRecorder() + handleMorning(w, httptest.NewRequest(http.MethodGet, "/morning", nil), core) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", w.Code) + } + body := w.Body.String() + if !strings.Contains(body, at.Local().Format("15:04")) { + t.Errorf("plan item not rendered in his clock (%s): %s", at.Local().Format("15:04"), body) + } + if strings.Contains(body, at.In(away).Format("15:04")) { + t.Errorf("plan item rendered in the stored zone: %s", body) + } +} diff --git a/cmd/mavweb/tasks.go b/cmd/mavweb/tasks.go index 2a860ee..8122228 100644 --- a/cmd/mavweb/tasks.go +++ b/cmd/mavweb/tasks.go @@ -300,11 +300,15 @@ func promoteCandidate(ctx context.Context, core ipc.CoreAPI, r *http.Request, id if err := core.SetTaskFields(ctx, id, doneWhen, blockedOn); err != nil { return "", err } - if due != nil { - wgt, err := formWeight(r) - if err != nil { - return "", err - } + wgt, err := formWeight(r) + if err != nil { + return "", err + } + // The importance select is posted whether or not a date is. This ran under + // `if due != nil`, so confirming a candidate as "срочно" with no deadline + // dropped the word on the floor — the row came back normal and nothing said + // why. A promote with neither field set still writes nothing. + if due != nil || wgt != 0 { if err := core.EditTask(ctx, id, text, due, wgt); err != nil { return "", err } diff --git a/cmd/mavweb/tasks_test.go b/cmd/mavweb/tasks_test.go index cbd639e..3f86f1c 100644 --- a/cmd/mavweb/tasks_test.go +++ b/cmd/mavweb/tasks_test.go @@ -32,6 +32,31 @@ type fakeTaskCore struct { statusErr error promoted bool + + // The promote path's two extra writes. + fields []any + edits []editCall + editErr error + fieldErr error +} + +// editCall records one EditTask, so a test can say what the form actually sent +// down rather than only that the promotion succeeded. +type editCall struct { + ID int64 + Text string + Due *time.Time + Weight int +} + +func (f *fakeTaskCore) EditTask(_ context.Context, id int64, text string, due *time.Time, weight int) error { + f.edits = append(f.edits, editCall{id, text, due, weight}) + return f.editErr +} + +func (f *fakeTaskCore) SetTaskFields(_ context.Context, id int64, doneWhen, blockedOn string) error { + f.fields = append(f.fields, []any{id, doneWhen, blockedOn}) + return f.fieldErr } func (f *fakeTaskCore) ListTasks(_ context.Context, status string) ([]ipc.Task, error) { @@ -220,6 +245,49 @@ func TestApplyTaskPostCarriesWeight(t *testing.T) { } } +// Confirming a candidate posts the importance select whether or not a date is +// set. The weight write hung off `if due != nil`, so "срочно" with no deadline +// was read off the form and thrown away, and the row came back normal. +func TestPromoteCandidateCarriesWeightWithoutADueDate(t *testing.T) { + core := &fakeTaskCore{} + form := url.Values{ + "action": {"promote"}, "id": {"4"}, "text": {"продлить страховку"}, + "done_when": {"полис на руках"}, "weight": {"3"}, + } + req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + handleTasks(httptest.NewRecorder(), req, core) + + if len(core.edits) != 1 { + t.Fatalf("edits = %+v, want the weight written once", core.edits) + } + if core.edits[0].Weight != 3 || core.edits[0].ID != 4 { + t.Errorf("edit = %+v, want id 4 at weight 3", core.edits[0]) + } + if core.edits[0].Due != nil { + t.Errorf("edit invented a due date: %v", core.edits[0].Due) + } + if core.statusVal != "open" { + t.Errorf("status = %q, want the candidate promoted", core.statusVal) + } +} + +// A promote with neither field set still writes nothing: the row is unchanged +// apart from its status, and an EditTask here would be a no-op that can fail. +func TestPromoteCandidateWithNoDateAndNoWeightDoesNotEdit(t *testing.T) { + core := &fakeTaskCore{} + form := url.Values{ + "action": {"promote"}, "id": {"4"}, "text": {"продлить страховку"}, + "done_when": {"полис на руках"}, "weight": {"0"}, + } + req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + handleTasks(httptest.NewRecorder(), req, core) + if len(core.edits) != 0 { + t.Errorf("edits = %+v, want none", core.edits) + } +} + // Out of range clamps rather than 400s; a non-number is a real client error. func TestApplyTaskPostClampsWeight(t *testing.T) { core := &fakeTaskCore{created: true}
atkindwhat
{{.At.Format "15:04"}}{{.At.Local.Format "15:04"}} {{.Kind}} {{if .Uncertain}}похоже, {{end}}{{.Text}}