diff --git a/internal/delivery/dispatcher.go b/internal/delivery/dispatcher.go index db91aa5..430b3ee 100644 --- a/internal/delivery/dispatcher.go +++ b/internal/delivery/dispatcher.go @@ -183,8 +183,13 @@ func (d *Dispatcher) DispatchReminder(ctx context.Context, pr PhrasedReminder, n out = append(out, Dispatch{Sendable: s}) } if d.cfg.Reminders != nil && len(out) > 0 { - if err := d.cfg.Reminders.MarkReminder(ctx, rd.Reminder.ID, "fired"); err != nil { - return out, fmt.Errorf("mark reminder fired: %w", err) + // 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 { + return out, fmt.Errorf("mark reminder fired: %w", err) + } } } return out, nil diff --git a/internal/loop/gather.go b/internal/loop/gather.go index e3d259f..4e6ea7c 100644 --- a/internal/loop/gather.go +++ b/internal/loop/gather.go @@ -10,6 +10,9 @@ package loop import ( "context" + "encoding/json" + "fmt" + "log" "time" "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 { return State{}, nil, err } + due, err = collapseReminders(ctx, g.store, due) + if err != nil { + return State{}, nil, err + } s := State{ Now: now, @@ -193,4 +200,56 @@ func readFact(ctx context.Context, s *store.Store, key string) (store.Fact, bool return store.Fact{}, false } 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 } \ No newline at end of file