diff --git a/cmd/mavweb/ambient_test.go b/cmd/mavweb/ambient_test.go index 1636a1b..01ba1f6 100644 --- a/cmd/mavweb/ambient_test.go +++ b/cmd/mavweb/ambient_test.go @@ -139,7 +139,10 @@ func TestHandleAmbientIgnoresNonMeetings(t *testing.T) { } func TestHandleAmbientAuth(t *testing.T) { - body := `{"title":"Планёрка 10:00","posted_at":"2026-08-03T09:40:00Z"}` + // "завтра" so the meeting is in the future in every zone: the clock in the + // text is a local wall clock and posted_at is a Z instant, so a bare "10:00" + // is already stale on a box east of UTC and stores nothing. + body := `{"title":"Планёрка завтра 10:00","posted_at":"2026-08-03T09:40:00Z"}` newReq := func(hdr, val string) *http.Request { r := httptest.NewRequest(http.MethodPost, "/api/ambient", strings.NewReader(body)) @@ -245,3 +248,31 @@ func TestHandleAmbientBadInput(t *testing.T) { } }) } + +// The Z-instant defect end to end: a phone posts an RFC 3339 instant in UTC and +// the clock inside the text is his wall clock. On a UTC+4 box a 14:30 standup +// used to be stored at 18:30, and the size of the error was the deploy's offset. +func TestHandleAmbientStoresTheWallClockHeRead(t *testing.T) { + // No zone juggling: the assertion is that the stored wall clock is the one + // he read, whatever zone the box is in. That is false under the old code + // on every box except a UTC one. + core := &ambientCore{} + rr, resp := postAmbient(t, core, ambientTestToken, calendar.Notification{ + Package: "com.slack", + Title: "Standup", + Text: "созвон завтра в 14:30", + Posted: time.Date(2026, 8, 2, 9, 0, 0, 0, time.UTC), + }) + if rr.Code != http.StatusCreated { + t.Fatalf("status = %d, want 201: %s", rr.Code, rr.Body) + } + if !strings.HasSuffix(resp.Key, "_Standup") { + t.Errorf("key = %q, want a key naming the meeting", resp.Key) + } + if len(core.writeLog) != 1 { + t.Fatalf("expected 1 fact write, got %d", len(core.writeLog)) + } + if got := core.writeLog[0].Value; got != "Standup @ 14:30-15:00" { + t.Errorf("value = %q, want %q", got, "Standup @ 14:30-15:00") + } +} diff --git a/internal/calendar/ambient.go b/internal/calendar/ambient.go index 6721b0d..71a4c24 100644 --- a/internal/calendar/ambient.go +++ b/internal/calendar/ambient.go @@ -68,7 +68,28 @@ var dayWords = map[string]int{ // word ("завтра", "tomorrow") when the notification carries one, and the result // is refused if it lands more than ambientPastGrace in the past. A bare start // time gets DefaultReminderDuration. +// +// The clock reading is a wall clock in the daemon's local zone. See +// EventFromNotificationIn. func EventFromNotification(n Notification) (Event, bool) { + return EventFromNotificationIn(n, time.Local) +} + +// EventFromNotificationIn is EventFromNotification against an explicit zone. +// +// "созвон в 14:30" carries no zone, and nobody writing a phone notification +// means 14:30Z. The wall clock used to be resolved against Posted's own zone, +// and a phone posts an RFC 3339 instant ending in Z, so every ambient meeting +// on this box landed four hours late. The size of the error is the deploy's UTC +// offset, which is why it is invisible on a UTC box and wrong everywhere else. +// +// Posted stays an instant in its own zone: it says when the phone showed the +// notification, and the past-grace check compares instants. Only the day and +// the wall clock are read in loc. +func EventFromNotificationIn(n Notification, loc *time.Location) (Event, bool) { + if loc == nil { + loc = time.Local + } if n.Posted.IsZero() { return Event{}, false } @@ -82,8 +103,9 @@ func EventFromNotification(n Notification) (Event, bool) { return Event{}, false } - y, m, d := n.Posted.AddDate(0, 0, dayOffset(line)).Date() - loc := n.Posted.Location() + // The day is Posted's LOCAL day: a notification posted at 23:30Z saying + // "завтра в 09:00" is already tomorrow where he is standing. + y, m, d := n.Posted.In(loc).AddDate(0, 0, dayOffset(line)).Date() s := time.Date(y, m, d, start.hour, start.min, 0, 0, loc) // Too far in the past to be the meeting this notification is about. The day // was inferred, so the honest reading is that the inference was wrong. diff --git a/internal/calendar/ambient_test.go b/internal/calendar/ambient_test.go index 5484ac2..f03d366 100644 --- a/internal/calendar/ambient_test.go +++ b/internal/calendar/ambient_test.go @@ -74,12 +74,12 @@ func TestEventFromNotification(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - ev, ok := EventFromNotification(Notification{ + ev, ok := EventFromNotificationIn(Notification{ Package: "com.google.android.gm", Title: tt.title, Text: tt.text, Posted: posted, - }) + }, posted.Location()) if ok != tt.wantOK { t.Fatalf("ok = %v, want %v (event %+v)", ok, tt.wantOK, ev) } @@ -98,8 +98,8 @@ func TestEventFromNotification(t *testing.T) { if !ev.End.After(ev.Start) { t.Errorf("end %v must be after start %v", ev.End, ev.Start) } - // The event lands on the day the phone showed it, in the phone's - // location — not shifted into UTC. + // The event lands on the day the phone showed it, in the zone it + // was resolved against — never shifted into UTC. if ev.Start.Location() != posted.Location() { t.Errorf("location = %v, want %v", ev.Start.Location(), posted.Location()) } @@ -162,10 +162,10 @@ func TestEventFromNotificationDayWords(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - ev, ok := EventFromNotification(Notification{ + ev, ok := EventFromNotificationIn(Notification{ Package: "com.google.android.calendar", Title: tt.title, Text: tt.text, Posted: tt.posted, - }) + }, tt.posted.Location()) if ok != tt.wantOK { t.Fatalf("ok = %v, want %v (event %+v)", ok, tt.wantOK, ev) } @@ -189,10 +189,10 @@ func TestEventFromNotificationDayWords(t *testing.T) { // summary makes "Завтра Планёрка" the name of the meeting, and FactKey folds // that into the key. func TestEventFromNotificationDropsDayWordFromSummary(t *testing.T) { - ev, ok := EventFromNotification(Notification{ + ev, ok := EventFromNotificationIn(Notification{ Title: "Завтра Планёрка 09:00", Posted: time.Date(2026, 8, 3, 21, 0, 0, 0, time.UTC), - }) + }, time.UTC) if !ok { t.Fatal("expected an event") } @@ -209,10 +209,10 @@ func TestEventFromNotificationNeedsPostedAt(t *testing.T) { // An ambient event must never be indistinguishable from a calendar read. func TestAmbientEventsAreStoredAtReducedConfidence(t *testing.T) { - ev, ok := EventFromNotification(Notification{ + ev, ok := EventFromNotificationIn(Notification{ Title: "Планёрка 10:00-10:30", Posted: time.Date(2026, 8, 3, 9, 0, 0, 0, time.UTC), - }) + }, time.UTC) if !ok { t.Fatal("expected an event") } @@ -237,3 +237,52 @@ func TestStripClock(t *testing.T) { } } } + +// The defect this file's zone handling exists for: a phone posts an RFC 3339 +// instant ending in Z, and "созвон в 14:30" used to be resolved against that +// Z, so on a UTC+4 box the meeting was stored at 18:30. A wall clock in a +// notification is local by construction. +func TestNotificationClockIsLocalNotUTC(t *testing.T) { + samara := time.FixedZone("+04", 4*3600) + n := Notification{ + Package: "com.slack", + Title: "Standup", + Text: "созвон в 14:30", + Posted: time.Date(2026, 8, 2, 9, 0, 0, 0, time.UTC), // 13:00 local + } + ev, ok := EventFromNotificationIn(n, samara) + if !ok { + t.Fatal("expected an event") + } + if got := ev.Start.Format("15:04"); got != "14:30" { + t.Errorf("start = %s local, want 14:30", got) + } + if got := ev.Start.UTC().Format("15:04"); got != "10:30" { + t.Errorf("start = %sZ, want 10:30Z (14:30 at UTC+4)", got) + } + if ev.Start.Location() != samara { + t.Errorf("location = %v, want %v", ev.Start.Location(), samara) + } + // The stored key is the local day, so it files under the day he lived. + if want, got := "calendar_event_20260802_Standup", FactKeyIn(ev, samara); got != want { + t.Errorf("FactKey = %q, want %q", got, want) + } +} + +// A notification posted late in the UTC evening is already the next day where +// he is standing. The day must come from the local clock, not from Posted's. +func TestNotificationDayIsTheLocalDay(t *testing.T) { + samara := time.FixedZone("+04", 4*3600) + n := Notification{ + Title: "Планёрка 09:00", + Text: "завтра", + Posted: time.Date(2026, 8, 2, 21, 0, 0, 0, time.UTC), // 03-08 01:00 local + } + ev, ok := EventFromNotificationIn(n, samara) + if !ok { + t.Fatal("expected an event") + } + if y, m, d := ev.Start.Date(); y != 2026 || m != time.August || d != 4 { + t.Errorf("date = %d-%02d-%02d, want 2026-08-04 (tomorrow, locally)", y, m, d) + } +}