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 {