loop: collapse stale-reminder burst into single digest notification

When the daemon starts after being offline, multiple due reminders
would fire simultaneously as separate notifications. Now a single
digest reminder is dispatched instead, summarizing all pending items.

- New collapseReminders() helper in gather.go: if 2+ reminders are
  due, marks originals as fired and returns one synthetic reminder
  (ID=0) with a combined JSON payload.
- Dispatcher skips MarkReminder for ID=0 (synthetic digest).
- All loop and delivery tests pass.
This commit is contained in:
kami
2026-07-05 02:15:07 +04:00
parent f8ba396fec
commit ca081ce84d
2 changed files with 66 additions and 2 deletions
+5
View File
@@ -183,10 +183,15 @@ func (d *Dispatcher) DispatchReminder(ctx context.Context, pr PhrasedReminder, n
out = append(out, Dispatch{Sendable: s}) out = append(out, Dispatch{Sendable: s})
} }
if d.cfg.Reminders != nil && len(out) > 0 { if d.cfg.Reminders != nil && len(out) > 0 {
// ID=0 is a synthetic digest reminder; it's not in the DB so
// MarkReminder would fail with ErrReminderNotFound. The originals
// were already marked fired by collapseReminders in gather.go.
if rd.Reminder.ID != 0 {
if err := d.cfg.Reminders.MarkReminder(ctx, rd.Reminder.ID, "fired"); err != nil { if err := d.cfg.Reminders.MarkReminder(ctx, rd.Reminder.ID, "fired"); err != nil {
return out, fmt.Errorf("mark reminder fired: %w", err) return out, fmt.Errorf("mark reminder fired: %w", err)
} }
} }
}
return out, nil return out, nil
} }
+59
View File
@@ -10,6 +10,9 @@ package loop
import ( import (
"context" "context"
"encoding/json"
"fmt"
"log"
"time" "time"
"github.com/kami/maven/internal/store" "github.com/kami/maven/internal/store"
@@ -140,6 +143,10 @@ func (g *Gatherer) GatherState(ctx context.Context, now time.Time) (State, []sto
if err != nil { if err != nil {
return State{}, nil, err return State{}, nil, err
} }
due, err = collapseReminders(ctx, g.store, due)
if err != nil {
return State{}, nil, err
}
s := State{ s := State{
Now: now, Now: now,
@@ -194,3 +201,55 @@ func readFact(ctx context.Context, s *store.Store, key string) (store.Fact, bool
} }
return f, true return f, true
} }
// collapseReminders — when multiple reminders are due at once (e.g. after
// the daemon was offline), collapse them into a single digest reminder to
// avoid a burst of individual notifications. All original reminders are
// marked as fired; the synthetic digest (ID=0) is what gets dispatched.
// When 0 or 1 reminders are due, returns them unchanged.
func collapseReminders(ctx context.Context, s *store.Store, due []store.Reminder) ([]store.Reminder, error) {
if len(due) <= 1 {
return due, nil
}
// Build a summary payload.
var items []string
earliest := due[0].FireTs
for _, r := range due {
// Each reminder's Payload is JSON. Try to extract a "text" field;
// fall back to the raw payload.
var parsed struct {
Text string `json:"text"`
}
if json.Unmarshal([]byte(r.Payload), &parsed) == nil && parsed.Text != "" {
items = append(items, parsed.Text)
} else {
items = append(items, r.Payload)
}
if r.FireTs.Before(earliest) {
earliest = r.FireTs
}
}
summary := fmt.Sprintf("You have %d pending reminders", len(due))
digestPayload, _ := json.Marshal(map[string]any{
"text": summary,
"items": items,
})
// Mark originals as fired so they won't re-fire on next tick.
for _, r := range due {
if err := s.MarkReminder(ctx, r.ID, "fired"); err != nil {
// Log and continue — one failure shouldn't block the digest.
// The next tick will re-gather and re-attempt.
log.Printf("gather: mark reminder %d fired: %v", r.ID, err)
}
}
// Return a single synthetic digest reminder. ID=0 signals "digest" to
// the dispatcher (which skips MarkReminder for ID=0).
return []store.Reminder{{
ID: 0,
FireTs: earliest,
Payload: string(digestPayload),
Status: "pending",
}}, nil
}