diff --git a/internal/store/facts.go b/internal/store/facts.go index d6590bf..5b7834d 100644 --- a/internal/store/facts.go +++ b/internal/store/facts.go @@ -90,6 +90,13 @@ func (s *Store) RecentFacts(ctx context.Context, n int) ([]Fact, error) { // the same answer. The source stays on each Fact, along with its confidence, so // the caller can hedge a reading it did not get from a calendar server — // filtering by source here would have thrown that judgement away. +// +// One row per event, not one per write. The facts table is append-only, so a +// standup moved from 14:00 to 16:00 leaves two rows under the same key, and the +// day plan used to recite both as if the owner had two meetings. Voided rows +// are excluded, the latest row wins within a source, and the best-evidenced +// source wins across them — a calendar read beats the notification relay that +// guessed at the same meeting. func (s *Store) CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error) { prefixFrom := calendar.KeyPrefixForDay(from) prefixTo := calendar.KeyPrefixForDay(to) @@ -104,7 +111,8 @@ func (s *Store) CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, FROM facts WHERE source IN (`+placeholders(len(sources))+`) AND key >= ? AND key < ? - ORDER BY key`, args...) + AND id NOT IN (SELECT voids_id FROM facts WHERE voids_id IS NOT NULL) + ORDER BY key, ts, id`, args...) if err != nil { return nil, fmt.Errorf("calendar events: %w", err) } @@ -117,7 +125,46 @@ func (s *Store) CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, } out = append(out, f) } - return out, rows.Err() + if err := rows.Err(); err != nil { + return nil, err + } + return latestPerCalendarKey(out), nil +} + +// latestPerCalendarKey reduces the append-only rows for one day to one row per +// event key. Input must be ordered by key then oldest-first, so the last row +// seen for a key and source is that source's current value. +func latestPerCalendarKey(in []Fact) []Fact { + type slot struct { + bySource map[string]Fact + order []string + } + var keys []string + byKey := map[string]*slot{} + for _, f := range in { + s, ok := byKey[f.Key] + if !ok { + s = &slot{bySource: map[string]Fact{}} + byKey[f.Key] = s + keys = append(keys, f.Key) + } + if _, seen := s.bySource[f.Source]; !seen { + s.order = append(s.order, f.Source) + } + s.bySource[f.Source] = f + } + out := make([]Fact, 0, len(keys)) + for _, k := range keys { + s := byKey[k] + best := s.bySource[s.order[0]] + for _, src := range s.order[1:] { + if s.bySource[src].Confidence > best.Confidence { + best = s.bySource[src] + } + } + out = append(out, best) + } + return out } // LatestFactBySource — provenance-scoped. A rule on `service_down` trusts only diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 3a46f99..81fd1c9 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -381,6 +381,66 @@ func TestCalendarEvents(t *testing.T) { } } +// A rescheduled meeting keeps its key and appends a row. The query must return +// the current value, not the history: reciting both told the owner he had two +// standups when one had been moved. +func TestCalendarEventsReturnsOneRowPerEvent(t *testing.T) { + store := newTestStore(t) + defer store.Close() + + ctx := context.Background() + day := time.Date(2026, 8, 3, 0, 0, 0, 0, time.UTC) + const key = "calendar_event_20260803_Standup" + + store.WriteFact(ctx, day.Add(14*time.Hour), KindEnv, key, + `"Standup @ 14:00-14:30"`, calendar.SourcePersonal, 1.0, sql.NullInt64{}) + store.WriteFact(ctx, day.Add(16*time.Hour), KindEnv, key, + `"Standup @ 16:00-16:30"`, calendar.SourcePersonal, 1.0, sql.NullInt64{}) + // The notification relay guessed at the same meeting. A calendar read is + // better evidence, so the hedged row must not displace it. + store.WriteFact(ctx, day.Add(17*time.Hour), KindEnv, key, + `"Standup @ 17:00-17:30"`, calendar.SourceAmbient, calendar.AmbientConfidence, sql.NullInt64{}) + + events, err := store.CalendarEvents(ctx, day, day.AddDate(0, 0, 1)) + if err != nil { + t.Fatalf("CalendarEvents: %v", err) + } + if len(events) != 1 { + t.Fatalf("got %d rows, want the current one only: %+v", len(events), events) + } + if events[0].Value != `"Standup @ 16:00-16:30"` { + t.Errorf("value = %q, want the latest calendar read", events[0].Value) + } +} + +// A voided calendar fact is gone, not history to recite. +func TestCalendarEventsSkipsVoidedRows(t *testing.T) { + store := newTestStore(t) + defer store.Close() + + ctx := context.Background() + day := time.Date(2026, 8, 3, 0, 0, 0, 0, time.UTC) + id, err := store.WriteFact(ctx, day.Add(14*time.Hour), KindEnv, "calendar_event_20260803_Cancelled", + `"Cancelled @ 14:00-14:30"`, calendar.SourcePersonal, 1.0, sql.NullInt64{}) + if err != nil { + t.Fatalf("WriteFact: %v", err) + } + if _, err := store.WriteFact(ctx, day.Add(15*time.Hour), KindEnv, "calendar_event_20260803_Cancelled", + `"Cancelled @ 14:00-14:30"`, calendar.SourcePersonal, 1.0, sql.NullInt64{Int64: id, Valid: true}); err != nil { + t.Fatalf("WriteFact void: %v", err) + } + + events, err := store.CalendarEvents(ctx, day, day.AddDate(0, 0, 1)) + if err != nil { + t.Fatalf("CalendarEvents: %v", err) + } + for _, e := range events { + if e.ID == id { + t.Fatalf("voided row %d came back: %+v", id, e) + } + } +} + // The work calendar arrives as relayed phone notifications, not a CalDAV read // (Vikunja #126). Those events belong in the same day's answer, and their // provenance has to survive the query so the caller can hedge them.