// Package morning is maven's morning routine engine — item #3 off the // 2026-07-20 backlog (Vikunja #280). // // 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. // Optional — a missing one is not worth a nudge on its own. // // Every item was implicitly required until 04-08-2026, because there was // no field, so a skipped stretch read exactly like skipped medication and // #280's first behaviour could not hold (Vikunja #473). A checklist where // everything is mandatory is a checklist he learns to ignore. // // It changes two things and nothing else: an all-optional routine never // nudges, and a nudge that does fire names the optional stragglers after // the required ones, in softer words. Evidence, the window and the day // plan treat both kinds alike — a missing optional item is still missing. Optional bool } // 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 REQUIRED item is still unevidenced. Missing // carries the optional stragglers too, so the one message she is allowed per // day per routine can mention them; they never cause it. type Candidate struct { Routine Routine Missing []Item } // Required reports the missing items that are not optional. The nudge fires on // these; the rest ride along. func Required(missing []Item) []Item { var out []Item for _, it := range missing { if !it.Optional { out = append(out, it) } } return out } // OptionalOnly is the other half of Required. func OptionalOnly(missing []Item) []Item { var out []Item for _, it := range missing { if it.Optional { out = append(out, it) } } return out } // Validate reports the first structural problem with a routine set: missing // name/items, an unparseable HH:MM, an inverted window, a duplicate routine or // item key, or an out-of-range weekday. Called at config load so a typo // surfaces at startup, not as a silently-broken checklist at runtime. // // Routine names must be unique because Due keys its once-a-day map by name. Two // routines sharing one would take turns suppressing each other's nudge. func Validate(routines []Routine) error { names := make(map[string]bool, len(routines)) for _, r := range routines { if r.Name == "" { return fmt.Errorf("morning: name is required") } if names[r.Name] { return fmt.Errorf("morning routine %q: duplicate name", r.Name) } names[r.Name] = true 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 } // missing lists the items of r with no evidence in [start, now]. func missing(r Routine, facts map[string]store.Fact, start, now time.Time) []Item { var out []Item for _, it := range r.Items { if !evidenced(it, facts, start, now) { out = append(out, it) } } return out } // Outstanding reports the items of a routine that today has no evidence for, // whether or not the window is still open. Evaluate answers "what is missing // right now" and goes silent the moment the window closes; the day plan asks a // different question, "what did today still not get done", and a skipped // routine is exactly what it is worth telling him. Nothing before the window // opens is outstanding yet, so the morning routine is not a complaint at 06:00. func Outstanding(r Routine, facts map[string]store.Fact, now time.Time) []Item { if !appliesToday(r, now) { return nil } start, ok := todayAt(r.WindowStart, now) if !ok || now.Before(start) { return nil } return missing(r, facts, start, now) } // 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 } skipped := missing(r, facts, start, now) // A day where only the optional items were skipped is a fine day, and // nagging about it is what teaches him to stop listening (Vikunja // #473). The optional ones still travel in Missing so the message can // mention them when it is being sent anyway. if len(Required(skipped)) == 0 { continue } if prev, seen := last[r.Name]; seen && sameDay(prev, now) { continue } last[r.Name] = now out = append(out, Candidate{Routine: r, Missing: skipped}) } 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 } // parseHHMM reads a five-character "HH:MM". Every digit is checked as a digit: // arithmetic alone lets a stray character cancel out, so "2 :00" used to load // as 04:00 and Validate passed the typo it exists to catch. func parseHHMM(s string) (hour, min int, ok bool) { if len(s) != 5 || s[2] != ':' { return 0, 0, false } for _, i := range [4]int{0, 1, 3, 4} { if s[i] < '0' || s[i] > '9' { 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 > 23 || m > 59 { return 0, 0, false } return h, m, true }