diff --git a/internal/loop/gate_test.go b/internal/loop/gate_test.go index 09bcb62..ed0cb1c 100644 --- a/internal/loop/gate_test.go +++ b/internal/loop/gate_test.go @@ -273,7 +273,6 @@ func TestRemindersBypassEverySuppressor(t *testing.T) { // passes every due reminder straight through with no snooze check, so a snoozed // reminder fires anyway. The test below is what the contract asks for. func TestRemindersStillHonourSnooze(t *testing.T) { - t.Skip("snooze is not applied to reminders — RemindDecisions ignores SnoozeUntil, internal/loop/loop.go:120") now := refTime() s := State{ @@ -292,7 +291,6 @@ func TestRemindersStillHonourSnooze(t *testing.T) { // unit tests above pass while nothing can ever populate the map. This asserts // the Gatherer actually produces a snooze map. func TestGathererPopulatesSnoozeUntil(t *testing.T) { - t.Skip("Gatherer never populates SnoozeUntil, so snooze cannot suppress anything at runtime, internal/loop/gather.go:153") ctx := context.Background() st, err := store.Open(ctx, t.TempDir()+"/m.db") diff --git a/internal/loop/gather.go b/internal/loop/gather.go index 405aa0f..c55919f 100644 --- a/internal/loop/gather.go +++ b/internal/loop/gather.go @@ -119,6 +119,14 @@ func (g *Gatherer) GatherState(ctx context.Context, now time.Time) (State, []sto return State{}, nil, err } + // live snoozes — "leave me alone until X", per rule. The `snoozed` outcome + // on the nudges table is the whole record; the store turns it into an + // expiry. Absent rules mean "not snoozed", which is what the gate reads. + snoozeUntil, err := g.store.SnoozedUntil(ctx, now) + if err != nil { + return State{}, nil, err + } + // env flags — QuietHours / CalendarBusy as config facts. // QuietHours: presence != reachability, sleep/quiet-hours handled separately // in the gate. We read a config `quiet_hours` fact for the boolean. @@ -150,7 +158,7 @@ func (g *Gatherer) GatherState(ctx context.Context, now time.Time) (State, []sto PresenceScore: score, Facts: facts, LastNudge: lastNudge, - SnoozeUntil: nil, // no snooze persistence yet — daemon wires in + SnoozeUntil: snoozeUntil, CooldownUntil: cooldownUntil, QuietHours: quiet, CalendarBusy: calBusy, diff --git a/internal/loop/loop.go b/internal/loop/loop.go index bc884fe..ae572d9 100644 --- a/internal/loop/loop.go +++ b/internal/loop/loop.go @@ -1,6 +1,7 @@ package loop import ( + "fmt" "time" "github.com/kami/maven/internal/store" @@ -106,25 +107,53 @@ func Tick(s State, rules []Rule) *Candidate { // ReminderDecision — a due reminder the daemon should deliver now. // NOT gated by the universal Gate (per spec: "wake me 7" fires in quiet hours; -// that's the point). Snooze still applies — represented by a separate -// snooze-until the gatherer consults; for the scaffold, fired-reminders move -// straight to MarkReminder(fired). +// that's the point). Snooze is the one part of restraint that still applies. type ReminderDecision struct { Reminder store.Reminder State State } -// RemindDecisions — returns all due reminders (without gating their delivery -// by restraint). Pure: accepts an already-filtered (due) list. The Gatherer -// produces that list from `fire_ts <= now AND pending`. +// ReminderSnoozeKey — the SnoozeUntil key that holds back every due reminder. +// Reminders have no rule name, so they share one key. A snooze aimed at a +// single reminder uses ReminderSnoozeKeyFor instead. +const ReminderSnoozeKey = "reminder" + +// ReminderSnoozeKeyFor — the SnoozeUntil key for one reminder by id. +func ReminderSnoozeKeyFor(id int64) string { + return fmt.Sprintf("%s:%d", ReminderSnoozeKey, id) +} + +// RemindDecisions — returns the due reminders the daemon should deliver. +// Pure: accepts an already-filtered (due) list. The Gatherer produces that +// list from `fire_ts <= now AND pending`. +// +// Quiet hours, presence and cooldown are deliberately NOT consulted — a +// reminder must wake you at 7 even in the middle of quiet hours. Only snooze +// holds one back. A held reminder stays pending, so it comes back once the +// snooze runs out. func RemindDecisions(s State, due []store.Reminder) []ReminderDecision { out := make([]ReminderDecision, 0, len(due)) for _, r := range due { + if reminderSnoozed(s, r) { + continue + } out = append(out, ReminderDecision{Reminder: r, State: s}) } return out } +// reminderSnoozed — true when a snooze on this reminder, or on reminders as a +// class, is still running. +func reminderSnoozed(s State, r store.Reminder) bool { + keys := []string{ReminderSnoozeKey, ReminderSnoozeKeyFor(r.ID)} + for _, k := range keys { + if until, ok := s.SnoozeUntil[k]; ok && s.Now.Before(until) { + return true + } + } + return false +} + // CooldownFor — helper for the Gatherer: given the active cooldown base // (the rule's static Base, OR the feedback tuner's persisted tuning) and the // last send ts, compute the wall-clock "cooldown-until" the gate will check.