Add the morning routine engine — a daily checklist, not four timers

Backlog item #3 (20-07-2026-BACKLOG.md). A morning routine is a checklist for
a daily window: several items, each evidenced by a fact key, completed in any
order, checked once near the end of the window. Modelling it as four
independent reminder timers would stack into exactly the kind of noise Maven is
supposed not to produce, so the engine nags at most once per day per routine
and only for what is actually still missing.

internal/morning follows the established pure-engine pattern (loop, routine,
pattern): no store, no clock of its own. Evaluate answers "what's still
missing" at any point; Due decides whether to nag. The impurity — reading
facts under the store lock, holding the last-nudge map across ticks — stays in
the tick driver, which calls Due each tick exactly as it does for loop.Rule
and routine.Routine.

Completion evidence is a fact key's latest non-voided value timestamped inside
today's window, so manual ("выпил воды", voice-tapped) and inferred (another
daemon writing the same key) are indistinguishable and both count. Weekdays
scopes which days a routine applies to, so weekday/weekend variants are two
routine rows rather than a special case in the engine.

Exposed read-only: a MorningStatus RPC over ipc, and a /morning page in mavweb
built on the same server-rendered shape as /trace — no live-update loop, since
checklist state moves on the scale of minutes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X5JApcrCRVGmqrxnhynSik
This commit is contained in:
kami
2026-07-30 23:49:10 +04:00
parent 76a6a007ef
commit 20184874b2
14 changed files with 726 additions and 13 deletions
+233
View File
@@ -0,0 +1,233 @@
// Package morning is maven's morning routine engine — item #3 off the
// 2026-07-20 backlog (see Maven/20-07-2026-BACKLOG.md).
//
// A Routine is NOT four independent reminder timers. It's a checklist for a
// daily window: several Items, each evidenced by a fact key, completed in
// any order, checked once near the end of the window. Maven should be able
// to answer "what's still missing" at any point (Evaluate), and should nag
// AT MOST once per day per routine when something got skipped (Due) — never
// fire four separate item timers that stack into noise.
//
// This package is pure, like internal/loop and internal/routine: no store,
// no clock of its own. The daemon's tick driver owns the impurity (reads
// facts under the store lock, holds the last-nudge map across ticks) and
// calls Due each tick, exactly as it does for loop.Rule and routine.Routine.
package morning
import (
"fmt"
"time"
"github.com/kami/maven/internal/store"
)
// Item — one checklist entry. FactKey is the fact whose latest non-voided
// value, if timestamped within today's window, counts as completion
// evidence — manual (voice-tapped "выпил воды") and inferred (another
// daemon writing the same key) are indistinguishable and both count, per
// the backlog's "manual and inferred completion evidence" requirement.
type Item struct {
Key string
FactKey string
Label string // RU text surfaced when this item is still missing.
}
// Routine — one daily checklist. WindowStart/WindowEnd are "HH:MM" local
// time and must not cross midnight (a morning routine doesn't span days).
// NudgeAt is when the engine checks for stragglers and nags once if
// anything's missing; empty defaults to WindowEnd (nag right as the window
// closes, not the moment it opens). Weekdays scopes which days this routine
// applies to — empty means every day; set it twice under different names
// for weekday/weekend variants.
type Routine struct {
Name string
Weekdays []time.Weekday
WindowStart string
WindowEnd string
NudgeAt string
Severity int
Items []Item
}
// Status — the checklist's state right now. Active is false when the
// routine doesn't apply today (weekday) or `now` falls outside its window;
// Missing/Completed are meaningless in that case.
type Status struct {
RoutineName string
Active bool
Missing []Item
Completed []Item
}
// Candidate — a routine that's due for its one-per-day nag: the window has
// reached NudgeAt and at least one item is still unevidenced.
type Candidate struct {
Routine Routine
Missing []Item
}
// Validate reports the first structural problem with a routine set: missing
// name/items, an unparseable HH:MM, an inverted window, a duplicate item key
// within a routine, or an out-of-range weekday. Called at config load so a
// typo surfaces at startup, not as a silently-broken checklist at runtime.
func Validate(routines []Routine) error {
for _, r := range routines {
if r.Name == "" {
return fmt.Errorf("morning: name is required")
}
if len(r.Items) == 0 {
return fmt.Errorf("morning routine %q: at least one item is required", r.Name)
}
startH, startM, ok := parseHHMM(r.WindowStart)
if !ok {
return fmt.Errorf("morning routine %q: bad window_start %q", r.Name, r.WindowStart)
}
endH, endM, ok := parseHHMM(r.WindowEnd)
if !ok {
return fmt.Errorf("morning routine %q: bad window_end %q", r.Name, r.WindowEnd)
}
if startH*60+startM >= endH*60+endM {
return fmt.Errorf("morning routine %q: window_start must be before window_end", r.Name)
}
if r.NudgeAt != "" {
if _, _, ok := parseHHMM(r.NudgeAt); !ok {
return fmt.Errorf("morning routine %q: bad nudge_at %q", r.Name, r.NudgeAt)
}
}
for _, w := range r.Weekdays {
if w < time.Sunday || w > time.Saturday {
return fmt.Errorf("morning routine %q: bad weekday %d", r.Name, w)
}
}
seen := make(map[string]bool, len(r.Items))
for _, it := range r.Items {
if it.Key == "" {
return fmt.Errorf("morning routine %q: item key is required", r.Name)
}
if it.FactKey == "" {
return fmt.Errorf("morning routine %q item %q: fact_key is required", r.Name, it.Key)
}
if seen[it.Key] {
return fmt.Errorf("morning routine %q: duplicate item key %q", r.Name, it.Key)
}
seen[it.Key] = true
}
}
return nil
}
// Evaluate reports the routine's current checklist state, pure over the
// given facts snapshot and clock reading. Callable any time — the "what's
// still missing" query path — not just at nudge time.
func Evaluate(r Routine, facts map[string]store.Fact, now time.Time) Status {
st := Status{RoutineName: r.Name}
if !appliesToday(r, now) {
return st
}
start, ok1 := todayAt(r.WindowStart, now)
end, ok2 := todayAt(r.WindowEnd, now)
if !ok1 || !ok2 || now.Before(start) || !now.Before(end) {
return st
}
st.Active = true
for _, it := range r.Items {
if evidenced(it, facts, start, now) {
st.Completed = append(st.Completed, it)
} else {
st.Missing = append(st.Missing, it)
}
}
return st
}
// Due returns the routines that have reached their nudge time today with at
// least one item still missing, and records `now` in `last` for each one
// returned so it fires at most once per calendar day. The caller owns
// `last` (the tick driver holds it across ticks, mirroring routine.Due).
func Due(routines []Routine, facts map[string]store.Fact, last map[string]time.Time, now time.Time) []Candidate {
var out []Candidate
for _, r := range routines {
if !appliesToday(r, now) {
continue
}
start, ok := todayAt(r.WindowStart, now)
if !ok {
continue
}
nudgeAtStr := r.NudgeAt
if nudgeAtStr == "" {
nudgeAtStr = r.WindowEnd
}
nudgeAt, ok := todayAt(nudgeAtStr, now)
if !ok || now.Before(nudgeAt) {
continue
}
var missing []Item
for _, it := range r.Items {
if !evidenced(it, facts, start, now) {
missing = append(missing, it)
}
}
if len(missing) == 0 {
continue
}
if prev, seen := last[r.Name]; seen && sameDay(prev, now) {
continue
}
last[r.Name] = now
out = append(out, Candidate{Routine: r, Missing: missing})
}
return out
}
// evidenced reports whether item has a non-voided fact timestamped within
// [windowStart, now] — evidence from before the window opened (e.g.
// yesterday's dose) doesn't count; evidence from the future can't exist.
func evidenced(it Item, facts map[string]store.Fact, windowStart, now time.Time) bool {
f, ok := facts[it.FactKey]
if !ok || f.Ts.IsZero() {
return false
}
return !f.Ts.Before(windowStart) && !f.Ts.After(now)
}
func appliesToday(r Routine, now time.Time) bool {
if len(r.Weekdays) == 0 {
return true
}
for _, w := range r.Weekdays {
if w == now.Weekday() {
return true
}
}
return false
}
// todayAt resolves an "HH:MM" clock reading against now's calendar date and
// location.
func todayAt(hhmm string, now time.Time) (time.Time, bool) {
h, m, ok := parseHHMM(hhmm)
if !ok {
return time.Time{}, false
}
y, mo, d := now.Date()
return time.Date(y, mo, d, h, m, 0, 0, now.Location()), true
}
func sameDay(a, b time.Time) bool {
ay, am, ad := a.Date()
by, bm, bd := b.Date()
return ay == by && am == bm && ad == bd
}
func parseHHMM(s string) (hour, min int, ok bool) {
if len(s) != 5 || s[2] != ':' {
return 0, 0, false
}
h := int(s[0]-'0')*10 + int(s[1]-'0')
m := int(s[3]-'0')*10 + int(s[4]-'0')
if h < 0 || h > 23 || m < 0 || m > 59 {
return 0, 0, false
}
return h, m, true
}
+184
View File
@@ -0,0 +1,184 @@
package morning
import (
"testing"
"time"
"github.com/kami/maven/internal/store"
)
func mkRoutine() Routine {
return Routine{
Name: "weekday_morning",
Weekdays: []time.Weekday{time.Monday, time.Tuesday, time.Wednesday, time.Thursday, time.Friday},
WindowStart: "08:00",
WindowEnd: "11:00",
Items: []Item{
{Key: "medicine", FactKey: "medicine", Label: "лекарство"},
{Key: "water", FactKey: "water", Label: "вода"},
{Key: "pets", FactKey: "pets", Label: "кот"},
},
}
}
func at(h, m int) time.Time {
return time.Date(2026, 7, 20, h, m, 0, 0, time.UTC) // 2026-07-20 is a Monday
}
func fact(ts time.Time) store.Fact { return store.Fact{Ts: ts} }
func TestValidate(t *testing.T) {
r := mkRoutine()
if err := Validate([]Routine{r}); err != nil {
t.Fatalf("valid routine rejected: %v", err)
}
bad := r
bad.Items = nil
if err := Validate([]Routine{bad}); err == nil {
t.Fatal("expected error for no items")
}
bad = r
bad.WindowStart = "25:00"
if err := Validate([]Routine{bad}); err == nil {
t.Fatal("expected error for bad window_start")
}
bad = r
bad.WindowStart, bad.WindowEnd = "11:00", "08:00"
if err := Validate([]Routine{bad}); err == nil {
t.Fatal("expected error for inverted window")
}
bad = r
bad.Items = append(bad.Items, Item{Key: "medicine", FactKey: "x"})
if err := Validate([]Routine{bad}); err == nil {
t.Fatal("expected error for duplicate item key")
}
}
func TestEvaluateInactiveOutsideWindow(t *testing.T) {
r := mkRoutine()
st := Evaluate(r, nil, at(7, 59))
if st.Active {
t.Fatal("expected inactive before window opens")
}
st = Evaluate(r, nil, at(11, 0))
if st.Active {
t.Fatal("expected inactive at/after window closes")
}
}
func TestEvaluateInactiveOnWrongWeekday(t *testing.T) {
r := mkRoutine() // weekdays only
saturday := time.Date(2026, 7, 25, 9, 0, 0, 0, time.UTC)
if Evaluate(r, nil, saturday).Active {
t.Fatal("expected inactive on a weekend day not in Weekdays")
}
}
func TestEvaluateMissingAndCompleted(t *testing.T) {
r := mkRoutine()
facts := map[string]store.Fact{
"medicine": fact(at(8, 30)),
}
st := Evaluate(r, facts, at(9, 0))
if !st.Active {
t.Fatal("expected active within window")
}
if len(st.Completed) != 1 || st.Completed[0].Key != "medicine" {
t.Fatalf("expected medicine completed, got %+v", st.Completed)
}
if len(st.Missing) != 2 {
t.Fatalf("expected 2 missing, got %+v", st.Missing)
}
}
func TestEvaluateEvidenceBeforeWindowDoesNotCount(t *testing.T) {
r := mkRoutine()
facts := map[string]store.Fact{
"medicine": fact(at(7, 0)), // before window opened today
}
st := Evaluate(r, facts, at(9, 0))
for _, it := range st.Completed {
if it.Key == "medicine" {
t.Fatal("stale (pre-window) evidence should not count as completion")
}
}
}
func TestDueFiresOnlyAtNudgeTimeWithMissingItems(t *testing.T) {
r := mkRoutine() // NudgeAt empty -> defaults to WindowEnd (11:00)
facts := map[string]store.Fact{
"medicine": fact(at(8, 30)),
"water": fact(at(8, 40)),
// pets missing
}
last := map[string]time.Time{}
if out := Due([]Routine{r}, facts, last, at(9, 0)); len(out) != 0 {
t.Fatalf("expected no candidate before nudge time, got %+v", out)
}
out := Due([]Routine{r}, facts, last, at(11, 0))
if len(out) != 1 {
t.Fatalf("expected 1 candidate at nudge time, got %d", len(out))
}
if len(out[0].Missing) != 1 || out[0].Missing[0].Key != "pets" {
t.Fatalf("expected only pets missing, got %+v", out[0].Missing)
}
}
func TestDueDoesNotRepeatSameDay(t *testing.T) {
r := mkRoutine()
facts := map[string]store.Fact{} // nothing done
last := map[string]time.Time{}
if out := Due([]Routine{r}, facts, last, at(11, 0)); len(out) != 1 {
t.Fatalf("expected first nudge to fire, got %d", len(out))
}
if out := Due([]Routine{r}, facts, last, at(11, 30)); len(out) != 0 {
t.Fatalf("expected no repeat nudge same day, got %d", len(out))
}
}
func TestDueFiresAgainNextDay(t *testing.T) {
r := mkRoutine()
facts := map[string]store.Fact{}
last := map[string]time.Time{}
Due([]Routine{r}, facts, last, at(11, 0))
tomorrow := time.Date(2026, 7, 21, 11, 0, 0, 0, time.UTC) // Tuesday
if out := Due([]Routine{r}, facts, last, tomorrow); len(out) != 1 {
t.Fatalf("expected nudge to fire again on a new day, got %d", len(out))
}
}
func TestDueSkipsWhenAllItemsComplete(t *testing.T) {
r := mkRoutine()
facts := map[string]store.Fact{
"medicine": fact(at(8, 30)),
"water": fact(at(8, 40)),
"pets": fact(at(8, 50)),
}
last := map[string]time.Time{}
if out := Due([]Routine{r}, facts, last, at(11, 0)); len(out) != 0 {
t.Fatalf("expected no nudge when all items complete, got %+v", out)
}
}
func TestDueRespectsExplicitNudgeAt(t *testing.T) {
r := mkRoutine()
r.NudgeAt = "10:00"
facts := map[string]store.Fact{}
last := map[string]time.Time{}
if out := Due([]Routine{r}, facts, last, at(9, 30)); len(out) != 0 {
t.Fatalf("expected no candidate before explicit nudge_at, got %+v", out)
}
if out := Due([]Routine{r}, facts, last, at(10, 0)); len(out) != 1 {
t.Fatalf("expected candidate at explicit nudge_at, got %d", len(out))
}
}