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:
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user