Add digest as a real outcome: suppressed care nudges get resurfaced, not lost
Vikunja #281. The interruption policy promised four outcomes — deliver_now, queue, digest, drop — but only three existed: a care candidate the restraint gate suppressed for quiet hours / away / calendar-busy simply vanished in loop.Tick's `continue`, with only the trace remembering why. internal/morning turned out not to be the natural drain: it's a fixed Item/FactKey checklist engine, not a generic message bundler, so gate- suppressed nudge text has nowhere to plug into its evidence model. Built a parallel (but small, reusing the outbox's shape) durable digest instead: - internal/store: digest_entries table + EnqueueDigestEntry (dedupes by rule+body, mirroring the delivery outbox's bodyHash), PendingDigestEntries, ExpireStaleDigestEntries, DrainDigestEntries (mark, never delete — an audit trail of what she actually said). - internal/loop: DigestEligible(severity, blockedBy) is the pure boundary — only genuine restraint blocks (quiet_hours/calendar_busy/presence) even qualify (cooldown/snooze are not "suppression"); within care, Sev2 (break) digests, Sev1 (water/meal — stale by the time anyone could resurface them) drops. High severity never digests; alarms bypass the gate and deliver unchanged, on purpose. - cmd/mavend/tick.go: each tick scans ExplainTick's trace for eligible blocked candidates, enqueues them, sweeps stale entries (24h expiry — the care rules are daily-cadence, so anything older is describing a day that's over), and drains the bundle only once the suppression reason has actually cleared, capped at 3 spoken items plus a trailing count so a digest can't turn into the exact nagging it was built to avoid. Tests: store-level round-trip/restart-survival/dedupe/expiry/drain, loop- level severity-boundary unit tests, and tick-level integration tests for the drain-only-when-clear and never-digest-high-severity behavior.
This commit is contained in:
@@ -286,6 +286,54 @@ func TestRemindersStillHonourSnooze(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------- digest eligibility ------------------------------
|
||||
|
||||
// A Sev2 care candidate (break) suppressed for a genuine restraint reason is
|
||||
// worth resurfacing later.
|
||||
func TestDigestEligibleSev2SuppressedByRestraint(t *testing.T) {
|
||||
for _, reason := range []string{"quiet_hours", "calendar_busy", "presence"} {
|
||||
if !DigestEligible(Sev2, reason) {
|
||||
t.Errorf("sev2 blocked by %q: want digest-eligible", reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A Sev1 care candidate (water/meal) never digests — a biological timer
|
||||
// nudge is stale by the time anyone could resurface it, so it just drops.
|
||||
func TestDigestEligibleSev1NeverDigests(t *testing.T) {
|
||||
for _, reason := range []string{"quiet_hours", "calendar_busy", "presence"} {
|
||||
if DigestEligible(Sev1, reason) {
|
||||
t.Errorf("sev1 blocked by %q: want drop, got digest-eligible", reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ops severities are never blocked by these reasons in practice (Gate only
|
||||
// applies quiet_hours/calendar_busy/presence to care severities), but the
|
||||
// boundary itself must refuse to digest a high severity even if asked —
|
||||
// alarms bypass the gate and deliver now, unchanged, never delayed.
|
||||
func TestDigestEligibleNeverDigestsHighSeverity(t *testing.T) {
|
||||
for _, sev := range []Severity{Sev3, Sev4} {
|
||||
for _, reason := range []string{"quiet_hours", "calendar_busy", "presence"} {
|
||||
if DigestEligible(sev, reason) {
|
||||
t.Errorf("sev%d blocked by %q: high severity must never digest", sev, reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// cooldown and snooze are not "suppression" in the digest sense — cooldown
|
||||
// means it was already said recently, snooze means the user asked to not
|
||||
// hear about it. Neither should resurface later just because the severity
|
||||
// matches.
|
||||
func TestDigestEligibleExcludesCooldownAndSnooze(t *testing.T) {
|
||||
for _, reason := range []string{"cooldown", "snooze", "inert_no_data", "predicate", ""} {
|
||||
if DigestEligible(Sev2, reason) {
|
||||
t.Errorf("sev2 blocked by %q: should not be digest-eligible", reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GAP — the gate reads State.SnoozeUntil, but the Gatherer hard-codes it to nil
|
||||
// (internal/loop/gather.go:153), so snooze is dead in the running daemon: the
|
||||
// unit tests above pass while nothing can ever populate the map. This asserts
|
||||
|
||||
Reference in New Issue
Block a user