From cc72f69769f1be2681e74a0c63f9424d1a8434e8 Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 5 Aug 2026 02:01:01 +0400 Subject: [PATCH] an ambient meeting suppresses a nudge for its own span (V-513) The ambient endpoint writes calendar_event_* and never calendar_busy, so a notification-derived meeting was good enough to recite out loud and not good enough to stop a nudge during it. Backwards: being wrong here costs one nudge. The loop gatherer now derives busy from the event facts themselves, so the expiry IS the meeting's span. No new level, no interval to choose, and no way for the suppression to outlive the meeting. calendar.FactSpan reads back what FactValue wrote; anything that does not parse says nothing about now. --- cmd/mavweb/ambient.go | 11 ++- internal/calendar/calendar.go | 64 +++++++++++++++ internal/loop/ambient_busy_test.go | 124 +++++++++++++++++++++++++++++ internal/loop/gather.go | 37 +++++++++ 4 files changed, 230 insertions(+), 6 deletions(-) create mode 100644 internal/loop/ambient_busy_test.go diff --git a/cmd/mavweb/ambient.go b/cmd/mavweb/ambient.go index 470ea53..1d98b48 100644 --- a/cmd/mavweb/ambient.go +++ b/cmd/mavweb/ambient.go @@ -31,12 +31,11 @@ import ( // not a guesser-of-truth, and a mailbox of noise rendered as invented meetings // is worse than a gap. // -// KNOWN GAP: this writes calendar_event_* and nothing else, so an ambient -// meeting is good enough to recite and not good enough to stop a nudge — -// calendar_busy is still written only by the CalDAV poller. That is backwards, -// since suppressing a nudge is the lower-risk use of a low-confidence signal. -// calendar_busy is a level rather than an event, so an ambient writer needs an -// expiry, which is its own task and not a change here. +// This writes calendar_event_* and nothing else, and since Vikunja #513 that is +// enough to stop a nudge as well as to recite: the loop gatherer reads the event +// family and asks whether any span covers the instant. So there is no ambient +// calendar_busy and no expiry to pick — a level needs one and an event carries +// its own. calendar_busy stays the CalDAV poller's key. // ambientMaxBody bounds the request. A notification is two short lines. const ambientMaxBody = 8 << 10 diff --git a/internal/calendar/calendar.go b/internal/calendar/calendar.go index cb62305..40bf11b 100644 --- a/internal/calendar/calendar.go +++ b/internal/calendar/calendar.go @@ -16,6 +16,7 @@ package calendar import ( "fmt" "sort" + "strconv" "strings" "time" "unicode" @@ -125,6 +126,69 @@ func FactSummary(value string) string { return value[:i] } +// EventKeyPrefix — every calendar event fact starts with this. The loop scans +// the family to work out whether a meeting covers right now (Vikunja #513). +const EventKeyPrefix = "calendar_event_" + +// FactSpan reads an event fact back into the instants it covers, against loc. +// The day comes from the key and the two clock readings from the value's +// "@ HH:MM-HH:MM" tail, which is everything FactValue wrote. +// +// ok is false for anything that does not parse. A fact whose span cannot be +// read tells you nothing about now, and guessing a span is how a signal that +// was meant to suppress one nudge starts suppressing all of them. +// +// An end at or before the start is read as crossing midnight, so a 23:30-00:15 +// meeting covers the quarter hour it actually covers. +func FactSpan(key, value string, loc *time.Location) (start, end time.Time, ok bool) { + if !strings.HasPrefix(key, EventKeyPrefix) { + return time.Time{}, time.Time{}, false + } + rest := key[len(EventKeyPrefix):] + if len(rest) < 8 { + return time.Time{}, time.Time{}, false + } + day, err := time.ParseInLocation("20060102", rest[:8], loc) + if err != nil { + return time.Time{}, time.Time{}, false + } + // SetValue stores a string fact JSON-encoded, so the value comes back + // quoted. Reading the tail off the quote is how this returned false for + // every real event the first time it ran. + if unq, err := strconv.Unquote(value); err == nil { + value = unq + } + i := strings.LastIndex(value, " @ ") + if i < 0 { + return time.Time{}, time.Time{}, false + } + tail := value[i+len(" @ "):] + from, to, found := strings.Cut(tail, "-") + if !found { + return time.Time{}, time.Time{}, false + } + sh, sm, ok1 := parseHM(strings.TrimSpace(from)) + eh, em, ok2 := parseHM(strings.TrimSpace(to)) + if !ok1 || !ok2 { + return time.Time{}, time.Time{}, false + } + start = day.Add(time.Duration(sh)*time.Hour + time.Duration(sm)*time.Minute) + end = day.Add(time.Duration(eh)*time.Hour + time.Duration(em)*time.Minute) + if !end.After(start) { + end = end.Add(24 * time.Hour) + } + return start, end, true +} + +// parseHM reads "15:04" and nothing else. +func parseHM(s string) (h, m int, ok bool) { + t, err := time.Parse("15:04", s) + if err != nil { + return 0, 0, false + } + return t.Hour(), t.Minute(), true +} + // KeyPrefixForDay is the fact-key prefix covering one calendar day. The store // range-scans between two of these. func KeyPrefixForDay(day time.Time) string { diff --git a/internal/loop/ambient_busy_test.go b/internal/loop/ambient_busy_test.go new file mode 100644 index 0000000..279f7a5 --- /dev/null +++ b/internal/loop/ambient_busy_test.go @@ -0,0 +1,124 @@ +package loop + +import ( + "context" + "path/filepath" + "testing" + "time" + + "github.com/kami/maven/internal/calendar" + "github.com/kami/maven/internal/store" +) + +// An ambient meeting suppresses a nudge for its own span and no longer +// (Vikunja #513). The span is read back off the event fact, so there is no +// expiry to configure and no way for it to outlive the meeting. +func TestAmbientEventSuppressesNudgesForItsOwnSpan(t *testing.T) { + ctx := context.Background() + s, err := store.Open(ctx, filepath.Join(t.TempDir(), "ambient_busy.db")) + if err != nil { + t.Fatalf("Open: %v", err) + } + t.Cleanup(func() { _ = s.Close() }) + + day := time.Date(2026, 8, 5, 0, 0, 0, 0, time.Local) + ev := calendar.Event{ + Summary: "Встреча с Аней", + Start: day.Add(14 * time.Hour), + End: day.Add(15 * time.Hour), + } + if _, err := s.SetValue(ctx, store.KindEnv, calendar.FactKey(ev), + calendar.SourceAmbient, calendar.FactValue(ev), day); err != nil { + t.Fatalf("SetValue: %v", err) + } + + g := NewGatherer(s, nil) + for _, tc := range []struct { + name string + now time.Time + busy bool + }{ + {"before it starts", day.Add(13*time.Hour + 59*time.Minute), false}, + {"at the first minute", day.Add(14 * time.Hour), true}, + {"in the middle", day.Add(14*time.Hour + 30*time.Minute), true}, + {"at the end instant", day.Add(15 * time.Hour), false}, + {"an hour after", day.Add(16 * time.Hour), false}, + } { + t.Run(tc.name, func(t *testing.T) { + st, _, err := g.GatherState(ctx, tc.now) + if err != nil { + t.Fatalf("GatherState: %v", err) + } + if st.CalendarBusy != tc.busy { + t.Fatalf("CalendarBusy = %v at %s, want %v", st.CalendarBusy, tc.now.Format("15:04"), tc.busy) + } + }) + } +} + +// A meeting on another day must not make today busy at the same clock reading. +// The day comes from the key, which is what makes this hold. +func TestAnEventOnAnotherDayDoesNotSuppress(t *testing.T) { + ctx := context.Background() + s, err := store.Open(ctx, filepath.Join(t.TempDir(), "ambient_busy_day.db")) + if err != nil { + t.Fatalf("Open: %v", err) + } + t.Cleanup(func() { _ = s.Close() }) + + yesterday := time.Date(2026, 8, 4, 0, 0, 0, 0, time.Local) + ev := calendar.Event{Summary: "Standup", Start: yesterday.Add(14 * time.Hour), End: yesterday.Add(15 * time.Hour)} + if _, err := s.SetValue(ctx, store.KindEnv, calendar.FactKey(ev), + calendar.SourceAmbient, calendar.FactValue(ev), yesterday); err != nil { + t.Fatalf("SetValue: %v", err) + } + + today := time.Date(2026, 8, 5, 14, 30, 0, 0, time.Local) + st, _, err := NewGatherer(s, nil).GatherState(ctx, today) + if err != nil { + t.Fatalf("GatherState: %v", err) + } + if st.CalendarBusy { + t.Fatal("yesterday's meeting suppressed a nudge today") + } +} + +func TestFactSpanReadsBackWhatFactValueWrote(t *testing.T) { + day := time.Date(2026, 8, 5, 0, 0, 0, 0, time.Local) + ev := calendar.Event{Summary: "Обед с мамой", Start: day.Add(13 * time.Hour), End: day.Add(13*time.Hour + 45*time.Minute)} + start, end, ok := calendar.FactSpan(calendar.FactKey(ev), calendar.FactValue(ev), time.Local) + if !ok { + t.Fatal("FactSpan could not read its own encoding") + } + if !start.Equal(ev.Start) || !end.Equal(ev.End) { + t.Fatalf("span = %s-%s, want %s-%s", start, end, ev.Start, ev.End) + } +} + +// An end at or before the start is a meeting crossing midnight, not a zero-length +// one. Reading it as zero-length would silently drop the suppression. +func TestFactSpanCrossesMidnight(t *testing.T) { + start, end, ok := calendar.FactSpan("calendar_event_20260805_Night", "Night @ 23:30-00:15", time.Local) + if !ok { + t.Fatal("FactSpan rejected a midnight-crossing event") + } + if got := end.Sub(start); got != 45*time.Minute { + t.Fatalf("span length = %s, want 45m", got) + } +} + +// A fact that does not parse says nothing about now. Guessing a span here is +// how one suppressed nudge becomes all of them. +func TestFactSpanRejectsWhatItCannotRead(t *testing.T) { + for _, tc := range []struct{ key, value string }{ + {"other_key_20260805_x", "x @ 10:00-11:00"}, + {"calendar_event_20260805_x", "x"}, + {"calendar_event_notadate_x", "x @ 10:00-11:00"}, + {"calendar_event_20260805_x", "x @ 25:00-11:00"}, + {"calendar_event_20260805_x", "x @ 10:00"}, + } { + if _, _, ok := calendar.FactSpan(tc.key, tc.value, time.Local); ok { + t.Errorf("FactSpan(%q, %q) parsed, want rejected", tc.key, tc.value) + } + } +} diff --git a/internal/loop/gather.go b/internal/loop/gather.go index 73e164f..de5db82 100644 --- a/internal/loop/gather.go +++ b/internal/loop/gather.go @@ -14,6 +14,7 @@ import ( "fmt" "time" + "github.com/kami/maven/internal/calendar" "github.com/kami/maven/internal/store" ) @@ -158,6 +159,19 @@ func (g *Gatherer) GatherState(ctx context.Context, now time.Time) (State, []sto if f, ok := readFact(ctx, g.store, "calendar_busy"); ok { calBusy = f.Value == "true" || f.Value == `"true"` } + // An ambient meeting suppresses a nudge too (Vikunja #513). It writes + // calendar_event_* and never calendar_busy, which is the CalDAV poller's + // level, so before this a low-confidence meeting was good enough to recite + // out loud and not good enough to stop a nudge during it. That is + // backwards: being wrong here costs one nudge he did not get. + // + // The expiry is the event's own span, which is why there is no new level + // and no interval to choose. A poller re-asserts a level every cycle and a + // notification arrives once; an event that already ended covers nothing, + // and one that has not started yet covers nothing either. + if !calBusy { + calBusy = g.eventCoversNow(ctx, now) + } // due reminders — gate-bypassing class. read here, the daemon emits them. due, err := g.store.DueReminders(ctx, now) @@ -212,6 +226,29 @@ func parseHHMM(s string) (hour, min int, ok bool) { return h, m, true } +// eventCoversNow reports whether any stored calendar event covers this instant. +// Read from the event facts themselves, so it holds for exactly as long as the +// meeting does — see the note at the call site. +// +// A read failure answers false: a meeting nobody can read about is not a reason +// to go quiet. +func (g *Gatherer) eventCoversNow(ctx context.Context, now time.Time) bool { + fam, err := g.store.LatestFactsByPrefix(ctx, calendar.EventKeyPrefix) + if err != nil { + return false + } + for _, f := range fam { + start, end, ok := calendar.FactSpan(f.Key, f.Value, now.Location()) + if !ok { + continue + } + if !now.Before(start) && now.Before(end) { + return true + } + } + return false +} + func readFact(ctx context.Context, s *store.Store, key string) (store.Fact, bool) { f, err := s.LatestFact(ctx, key) if err != nil {