From 1fe03f7a51f715f47adb44dfb800a062d850a340 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 11:37:14 +0400 Subject: [PATCH] calendar: a notification's 14:30 is 14:30 here (V-482) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A relay that posts its instant as `2026-08-02T09:00:00Z` handed the wall clock inside the text that same zone, so «созвон в 14:30» was built as 14:30 UTC and read back as 18:30 on this UTC+4 box. Every ambient event landed late by the deploy's own offset, and correct on a UTC box, which is why no test caught it. Posted is an instant and carries a zone. The clock reading is a wall clock and carries none, so it resolves against the daemon's zone now. The tests pin time.Local to +04 in TestMain, so the four hours show up on a UTC runner too. Co-Authored-By: Claude Opus 5 --- internal/calendar/ambient.go | 9 ++++++ internal/calendar/ambient_test.go | 52 +++++++++++++++++++++++++------ 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/internal/calendar/ambient.go b/internal/calendar/ambient.go index 6721b0d..e7cc3af 100644 --- a/internal/calendar/ambient.go +++ b/internal/calendar/ambient.go @@ -68,10 +68,19 @@ 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 read in the daemon's zone (Vikunja #482). Posted is an +// instant and carries an offset; "созвон в 14:30" is a wall clock and carries +// none, so the zone has to come from somewhere else. A relay that posts +// "2026-08-02T09:00:00Z" used to make that 14:30 UTC, which stored an 18:30 +// meeting on a UTC+4 box — wrong by the deploy's own offset, and invisible on a +// UTC box. The owner's phone and the box share a zone, so the box's zone is the +// honest reading of a bare wall clock. func EventFromNotification(n Notification) (Event, bool) { if n.Posted.IsZero() { return Event{}, false } + n.Posted = n.Posted.In(time.Local) line := strings.TrimSpace(n.Title + " " + n.Text) start, end, ok := parseTimeRange(line) if !ok { diff --git a/internal/calendar/ambient_test.go b/internal/calendar/ambient_test.go index 5484ac2..cdebaf5 100644 --- a/internal/calendar/ambient_test.go +++ b/internal/calendar/ambient_test.go @@ -1,12 +1,22 @@ package calendar import ( + "os" "testing" "time" ) +// A bare clock reading in a notification is read in the daemon's zone, so every +// test here needs a known one. UTC+4 is the deploy's (Europe/Samara) and it is +// the offset the 18:30 bug was measured at, so a regression shows up as four +// hours rather than as nothing at all on a UTC runner. +func TestMain(m *testing.M) { + time.Local = time.FixedZone("+04", 4*3600) + os.Exit(m.Run()) +} + func TestEventFromNotification(t *testing.T) { - posted := time.Date(2026, 8, 3, 9, 40, 0, 0, time.FixedZone("+04", 4*3600)) + posted := time.Date(2026, 8, 3, 9, 40, 0, 0, time.Local) tests := []struct { name string @@ -98,10 +108,10 @@ 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. - if ev.Start.Location() != posted.Location() { - t.Errorf("location = %v, want %v", ev.Start.Location(), posted.Location()) + // The event lands on the day the phone showed it, in the daemon's + // zone — the clock reading is a wall clock, not an instant. + if ev.Start.Location() != time.Local { + t.Errorf("location = %v, want %v", ev.Start.Location(), time.Local) } if y, m, d := ev.Start.Date(); y != 2026 || m != time.August || d != 3 { t.Errorf("date = %d-%02d-%02d, want 2026-08-03", y, m, d) @@ -115,8 +125,7 @@ func TestEventFromNotification(t *testing.T) { // the meeting twelve hours in the past and filed it under today in FactKey. A // wrong meeting stored is worse than nothing stored. func TestEventFromNotificationDayWords(t *testing.T) { - loc := time.FixedZone("+04", 4*3600) - evening := time.Date(2026, 8, 3, 21, 0, 0, 0, loc) + evening := time.Date(2026, 8, 3, 21, 0, 0, 0, time.Local) tests := []struct { name string @@ -191,7 +200,7 @@ func TestEventFromNotificationDayWords(t *testing.T) { func TestEventFromNotificationDropsDayWordFromSummary(t *testing.T) { ev, ok := EventFromNotification(Notification{ Title: "Завтра Планёрка 09:00", - Posted: time.Date(2026, 8, 3, 21, 0, 0, 0, time.UTC), + Posted: time.Date(2026, 8, 3, 21, 0, 0, 0, time.Local), }) if !ok { t.Fatal("expected an event") @@ -201,6 +210,31 @@ func TestEventFromNotificationDropsDayWordFromSummary(t *testing.T) { } } +// Vikunja #482. A relay that posts its instant as UTC used to hand the wall +// clock inside the text the same zone, so "созвон в 14:30" was stored as 14:30Z +// and read back as 18:30 on a UTC+4 box — late by exactly the deploy's offset, +// and correct-looking on a UTC one. Nobody writes a notification meaning 14:30Z. +func TestEventFromNotificationReadsTheClockAsLocalTime(t *testing.T) { + ev, ok := EventFromNotification(Notification{ + Package: "com.slack", + Title: "Standup", + Text: "созвон в 14:30", + Posted: time.Date(2026, 8, 2, 9, 0, 0, 0, time.UTC), // 13:00 local + }) + if !ok { + t.Fatal("expected an event") + } + if got := ev.Start.Format("15:04"); got != "14:30" { + t.Errorf("start = %s, want 14:30 local", got) + } + if ev.Start.Location() != time.Local { + t.Errorf("location = %v, want %v", ev.Start.Location(), time.Local) + } + if got, want := FactKey(ev), "calendar_event_20260802_Standup"; got != want { + t.Errorf("fact key = %q, want %q", got, want) + } +} + func TestEventFromNotificationNeedsPostedAt(t *testing.T) { if _, ok := EventFromNotification(Notification{Title: "Планёрка 10:00"}); ok { t.Error("a notification with no posted_at has no date to sit on") @@ -211,7 +245,7 @@ func TestEventFromNotificationNeedsPostedAt(t *testing.T) { func TestAmbientEventsAreStoredAtReducedConfidence(t *testing.T) { ev, ok := EventFromNotification(Notification{ Title: "Планёрка 10:00-10:30", - Posted: time.Date(2026, 8, 3, 9, 0, 0, 0, time.UTC), + Posted: time.Date(2026, 8, 3, 9, 0, 0, 0, time.Local), }) if !ok { t.Fatal("expected an event")