calendar: a notification's 14:30 is 14:30 here (V-482)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -68,10 +68,19 @@ var dayWords = map[string]int{
|
|||||||
// word ("завтра", "tomorrow") when the notification carries one, and the result
|
// word ("завтра", "tomorrow") when the notification carries one, and the result
|
||||||
// is refused if it lands more than ambientPastGrace in the past. A bare start
|
// is refused if it lands more than ambientPastGrace in the past. A bare start
|
||||||
// time gets DefaultReminderDuration.
|
// 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) {
|
func EventFromNotification(n Notification) (Event, bool) {
|
||||||
if n.Posted.IsZero() {
|
if n.Posted.IsZero() {
|
||||||
return Event{}, false
|
return Event{}, false
|
||||||
}
|
}
|
||||||
|
n.Posted = n.Posted.In(time.Local)
|
||||||
line := strings.TrimSpace(n.Title + " " + n.Text)
|
line := strings.TrimSpace(n.Title + " " + n.Text)
|
||||||
start, end, ok := parseTimeRange(line)
|
start, end, ok := parseTimeRange(line)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -1,12 +1,22 @@
|
|||||||
package calendar
|
package calendar
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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) {
|
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 {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -98,10 +108,10 @@ func TestEventFromNotification(t *testing.T) {
|
|||||||
if !ev.End.After(ev.Start) {
|
if !ev.End.After(ev.Start) {
|
||||||
t.Errorf("end %v must be after start %v", ev.End, 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
|
// The event lands on the day the phone showed it, in the daemon's
|
||||||
// location — not shifted into UTC.
|
// zone — the clock reading is a wall clock, not an instant.
|
||||||
if ev.Start.Location() != posted.Location() {
|
if ev.Start.Location() != time.Local {
|
||||||
t.Errorf("location = %v, want %v", ev.Start.Location(), posted.Location())
|
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 {
|
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)
|
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
|
// the meeting twelve hours in the past and filed it under today in FactKey. A
|
||||||
// wrong meeting stored is worse than nothing stored.
|
// wrong meeting stored is worse than nothing stored.
|
||||||
func TestEventFromNotificationDayWords(t *testing.T) {
|
func TestEventFromNotificationDayWords(t *testing.T) {
|
||||||
loc := time.FixedZone("+04", 4*3600)
|
evening := time.Date(2026, 8, 3, 21, 0, 0, 0, time.Local)
|
||||||
evening := time.Date(2026, 8, 3, 21, 0, 0, 0, loc)
|
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -191,7 +200,7 @@ func TestEventFromNotificationDayWords(t *testing.T) {
|
|||||||
func TestEventFromNotificationDropsDayWordFromSummary(t *testing.T) {
|
func TestEventFromNotificationDropsDayWordFromSummary(t *testing.T) {
|
||||||
ev, ok := EventFromNotification(Notification{
|
ev, ok := EventFromNotification(Notification{
|
||||||
Title: "Завтра Планёрка 09:00",
|
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 {
|
if !ok {
|
||||||
t.Fatal("expected an event")
|
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) {
|
func TestEventFromNotificationNeedsPostedAt(t *testing.T) {
|
||||||
if _, ok := EventFromNotification(Notification{Title: "Планёрка 10:00"}); ok {
|
if _, ok := EventFromNotification(Notification{Title: "Планёрка 10:00"}); ok {
|
||||||
t.Error("a notification with no posted_at has no date to sit on")
|
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) {
|
func TestAmbientEventsAreStoredAtReducedConfidence(t *testing.T) {
|
||||||
ev, ok := EventFromNotification(Notification{
|
ev, ok := EventFromNotification(Notification{
|
||||||
Title: "Планёрка 10:00-10:30",
|
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 {
|
if !ok {
|
||||||
t.Fatal("expected an event")
|
t.Fatal("expected an event")
|
||||||
|
|||||||
Reference in New Issue
Block a user