calendar, mavweb: read the notification clock as his wall clock (V-482)
A phone posts an RFC 3339 instant ending in Z, and the clock inside the text is a wall clock nobody means in UTC. The wall clock used to be resolved against Posted's own zone, so on this UTC+4 box a 14:30 standup was stored at 18:30. The size of the error is the deploy's offset, which is why the tests never saw it: they ran on a UTC box. EventFromNotificationIn takes the zone explicitly and EventFromNotification passes time.Local. The day comes from Posted's local day too, since a notification posted at 23:30Z saying "завтра" is already tomorrow where he is standing. Posted itself stays an instant, so the past-grace check still compares instants. The two handler fixtures said a bare "10:00" against a 09:40Z post, which is stale once the clock is read locally. They say "завтра" now, so they mean a future meeting in every zone. internal/calendar and cmd/mavweb pass under UTC, Europe/Samara, America/Los_Angeles, Pacific/Kiritimati and Asia/Kathmandu.
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user