2815adee03
Item carried only Key, FactKey and Label, so every checklist entry was implicitly required and behaviour 1 of #280 could not hold at all. It was not thin config — there was no field to set. Item.Optional, `"optional": true` in the routine config, default false, so a routine written before today behaves exactly as it did. Due now fires on a missing required item and not on an optional one, and the optional stragglers still travel in Missing so the one message per day per routine can name them 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 — it just does not earn a nudge, because a checklist where everything is mandatory is one he learns to ignore.
298 lines
9.7 KiB
Go
298 lines
9.7 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 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
var missing []Item
|
|
for _, it := range r.Items {
|
|
if !evidenced(it, facts, start, now) {
|
|
missing = append(missing, it)
|
|
}
|
|
}
|
|
return missing
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
// 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(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
|
|
}
|