5447f08c06
Both Due functions key their last-fired map by routine name, so two routines sharing a name took turns suppressing each other and one of them never fired. Validate now rejects a duplicate name in either package. parseHHMM checked the digits arithmetically, which let a stray character cancel out: window_start of 2 :00 loaded as 04:00 and passed the validation that exists to catch that typo. Each of the four positions is now checked as a digit, which makes the negative bounds unreachable and they are gone. Folded the three copies of the unevidenced-item loop in Evaluate, Outstanding and Due into one helper.
314 lines
10 KiB
Go
314 lines
10 KiB
Go
// 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
|
|
}
|