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:
+11
-6
@@ -179,6 +179,9 @@ func (l *lockedAPI) Chat(ctx context.Context, text string) (string, error) {
|
|||||||
func (l *lockedAPI) TickTrace(ctx context.Context) (ipc.TickTrace, error) {
|
func (l *lockedAPI) TickTrace(ctx context.Context) (ipc.TickTrace, error) {
|
||||||
return ipc.TickTrace{}, errLocked
|
return ipc.TickTrace{}, errLocked
|
||||||
}
|
}
|
||||||
|
func (l *lockedAPI) MorningStatus(ctx context.Context) ([]ipc.MorningRoutineStatus, error) {
|
||||||
|
return nil, errLocked
|
||||||
|
}
|
||||||
|
|
||||||
func run(args []string) error {
|
func run(args []string) error {
|
||||||
cfgPath := flag.String("config", defaultConfigPath(), "path to mavend JSON config")
|
cfgPath := flag.String("config", defaultConfigPath(), "path to mavend JSON config")
|
||||||
@@ -341,12 +344,13 @@ func run(args []string) error {
|
|||||||
tickInterval := time.Duration(cfg.TickInterval)
|
tickInterval := time.Duration(cfg.TickInterval)
|
||||||
repeatInterval := time.Duration(cfg.RepeatInterval)
|
repeatInterval := time.Duration(cfg.RepeatInterval)
|
||||||
autotuneInterval := time.Duration(cfg.AutotuneInterval)
|
autotuneInterval := time.Duration(cfg.AutotuneInterval)
|
||||||
tl = newTickLoop(st, gatherer, dispatcher, phr, rules, tickInterval, repeatInterval, autotuneInterval, cfg.Digest, routinesFromConfig(cfg.Routines))
|
tl = newTickLoop(st, gatherer, dispatcher, phr, rules, tickInterval, repeatInterval, autotuneInterval, cfg.Digest, routinesFromConfig(cfg.Routines), config.MorningRoutinesFromConfig(cfg.MorningRoutines))
|
||||||
factWorker = newFactEnrichmentWorker(st, eco, time.Duration(cfg.FactEnrichmentInterval))
|
factWorker = newFactEnrichmentWorker(st, eco, time.Duration(cfg.FactEnrichmentInterval))
|
||||||
|
|
||||||
coreAPI = &daemonAPI{
|
coreAPI = &daemonAPI{
|
||||||
CoreAPI: ipc.NewStoreAPI(st),
|
CoreAPI: ipc.NewStoreAPI(st),
|
||||||
getTrace: tl.trace,
|
getTrace: tl.trace,
|
||||||
|
getMorningStatus: func(ctx context.Context) []ipc.MorningRoutineStatus { return tl.morningStatus(ctx, time.Now()) },
|
||||||
}
|
}
|
||||||
if voiceW != nil && voiceW.handler != nil {
|
if voiceW != nil && voiceW.handler != nil {
|
||||||
api := coreAPI.(*daemonAPI)
|
api := coreAPI.(*daemonAPI)
|
||||||
@@ -501,13 +505,14 @@ func run(args []string) error {
|
|||||||
tickInterval := time.Duration(cfg.TickInterval)
|
tickInterval := time.Duration(cfg.TickInterval)
|
||||||
repeatInterval := time.Duration(cfg.RepeatInterval)
|
repeatInterval := time.Duration(cfg.RepeatInterval)
|
||||||
autotuneInterval := time.Duration(cfg.AutotuneInterval)
|
autotuneInterval := time.Duration(cfg.AutotuneInterval)
|
||||||
tl = newTickLoop(st, gatherer, dispatcher, phr, rules, tickInterval, repeatInterval, autotuneInterval, cfg.Digest, routinesFromConfig(cfg.Routines))
|
tl = newTickLoop(st, gatherer, dispatcher, phr, rules, tickInterval, repeatInterval, autotuneInterval, cfg.Digest, routinesFromConfig(cfg.Routines), config.MorningRoutinesFromConfig(cfg.MorningRoutines))
|
||||||
factWorker = newFactEnrichmentWorker(st, eco, time.Duration(cfg.FactEnrichmentInterval))
|
factWorker = newFactEnrichmentWorker(st, eco, time.Duration(cfg.FactEnrichmentInterval))
|
||||||
|
|
||||||
// Swap the CoreAPI from lockedAPI to the real store adapter.
|
// Swap the CoreAPI from lockedAPI to the real store adapter.
|
||||||
newAPI := &daemonAPI{
|
newAPI := &daemonAPI{
|
||||||
CoreAPI: ipc.NewStoreAPI(st),
|
CoreAPI: ipc.NewStoreAPI(st),
|
||||||
getTrace: tl.trace,
|
getTrace: tl.trace,
|
||||||
|
getMorningStatus: func(ctx context.Context) []ipc.MorningRoutineStatus { return tl.morningStatus(ctx, time.Now()) },
|
||||||
}
|
}
|
||||||
if voiceW != nil && voiceW.handler != nil {
|
if voiceW != nil && voiceW.handler != nil {
|
||||||
newAPI.chatFn = voiceW.handler.handleText
|
newAPI.chatFn = voiceW.handler.handleText
|
||||||
|
|||||||
+115
-2
@@ -23,6 +23,7 @@ import (
|
|||||||
"github.com/kami/maven/internal/delivery"
|
"github.com/kami/maven/internal/delivery"
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
"github.com/kami/maven/internal/loop"
|
"github.com/kami/maven/internal/loop"
|
||||||
|
"github.com/kami/maven/internal/morning"
|
||||||
"github.com/kami/maven/internal/phraser"
|
"github.com/kami/maven/internal/phraser"
|
||||||
"github.com/kami/maven/internal/routine"
|
"github.com/kami/maven/internal/routine"
|
||||||
"github.com/kami/maven/internal/store"
|
"github.com/kami/maven/internal/store"
|
||||||
@@ -61,6 +62,11 @@ type tickLoop struct {
|
|||||||
routines []routine.Routine
|
routines []routine.Routine
|
||||||
routineLast map[string]time.Time
|
routineLast map[string]time.Time
|
||||||
|
|
||||||
|
// morningRoutines — daily checklists (see internal/morning). morningLast
|
||||||
|
// tracks the per-routine last-nudge day, mirroring routineLast.
|
||||||
|
morningRoutines []morning.Routine
|
||||||
|
morningLast map[string]time.Time
|
||||||
|
|
||||||
// digestQ — in-memory queue of eligible nudges waiting for batch flush.
|
// digestQ — in-memory queue of eligible nudges waiting for batch flush.
|
||||||
// populated when digestCfg != nil && digestCfg.Enabled.
|
// populated when digestCfg != nil && digestCfg.Enabled.
|
||||||
digestQ []QueuedNudge
|
digestQ []QueuedNudge
|
||||||
@@ -85,6 +91,7 @@ func newTickLoop(
|
|||||||
tickInterval, repeatInterval, autotuneInterval time.Duration,
|
tickInterval, repeatInterval, autotuneInterval time.Duration,
|
||||||
digestCfg *config.DigestConfig,
|
digestCfg *config.DigestConfig,
|
||||||
routines []routine.Routine,
|
routines []routine.Routine,
|
||||||
|
morningRoutines []morning.Routine,
|
||||||
) *tickLoop {
|
) *tickLoop {
|
||||||
return &tickLoop{
|
return &tickLoop{
|
||||||
store: st,
|
store: st,
|
||||||
@@ -99,6 +106,8 @@ func newTickLoop(
|
|||||||
digestQ: nil,
|
digestQ: nil,
|
||||||
routines: routines,
|
routines: routines,
|
||||||
routineLast: make(map[string]time.Time),
|
routineLast: make(map[string]time.Time),
|
||||||
|
morningRoutines: morningRoutines,
|
||||||
|
morningLast: make(map[string]time.Time),
|
||||||
lastPhrase: make(map[string]delivery.PhrasedNudge),
|
lastPhrase: make(map[string]delivery.PhrasedNudge),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -177,6 +186,11 @@ func (t *tickLoop) tick(ctx context.Context, now time.Time) {
|
|||||||
// LLM-phrased — so a routine can't hallucinate. severity comes from config.
|
// LLM-phrased — so a routine can't hallucinate. severity comes from config.
|
||||||
t.fireRoutines(ctx, now, state)
|
t.fireRoutines(ctx, now, state)
|
||||||
|
|
||||||
|
// morning routines: daily checklists (medicine/water/pets/...), nagged at
|
||||||
|
// most once per day per routine, and only for items still unevidenced at
|
||||||
|
// nudge time. See internal/morning for the "why not four timers" rationale.
|
||||||
|
t.fireMorningRoutines(ctx, now, state)
|
||||||
|
|
||||||
// reminders: gate-bypassing class. fired once, marked after a successful
|
// reminders: gate-bypassing class. fired once, marked after a successful
|
||||||
// delivery. a failed send leaves the reminder pending — the next tick
|
// delivery. a failed send leaves the reminder pending — the next tick
|
||||||
// re-gathers and re-attempts.
|
// re-gathers and re-attempts.
|
||||||
@@ -363,6 +377,97 @@ func (t *tickLoop) fireRoutines(ctx context.Context, now time.Time, state loop.S
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fireMorningRoutines checks each configured checklist against today's facts
|
||||||
|
// and dispatches a nag listing exactly what's still missing, at most once per
|
||||||
|
// routine per calendar day. Fact reads happen here (not in loop.Gatherer)
|
||||||
|
// because the item↔fact-key mapping is morning-routine-specific, not a rule
|
||||||
|
// concern — pulling it into the shared gather path would leak that mapping
|
||||||
|
// into loop's "rules declare wanted keys" contract. Bodies are literal
|
||||||
|
// operator text (item labels joined), not LLM-phrased, same rationale as
|
||||||
|
// cron routines: deterministic, can't hallucinate a checklist item.
|
||||||
|
func (t *tickLoop) fireMorningRoutines(ctx context.Context, now time.Time, state loop.State) {
|
||||||
|
if len(t.morningRoutines) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
facts := t.gatherMorningFacts(ctx)
|
||||||
|
|
||||||
|
for _, cand := range morning.Due(t.morningRoutines, facts, t.morningLast, now) {
|
||||||
|
labels := make([]string, len(cand.Missing))
|
||||||
|
for i, it := range cand.Missing {
|
||||||
|
labels[i] = it.Label
|
||||||
|
}
|
||||||
|
body := fmt.Sprintf("%s: не сделано — %s", cand.Routine.Name, strings.Join(labels, ", "))
|
||||||
|
pn := delivery.PhrasedNudge{
|
||||||
|
Candidate: loop.Candidate{
|
||||||
|
Rule: loop.Rule{Name: "morning:" + cand.Routine.Name, Severity: loop.Severity(cand.Routine.Severity)},
|
||||||
|
Severity: loop.Severity(cand.Routine.Severity),
|
||||||
|
State: state,
|
||||||
|
},
|
||||||
|
Body: body,
|
||||||
|
Summary: body,
|
||||||
|
}
|
||||||
|
if _, err := t.dispatcher.DispatchNudge(ctx, pn, now); err != nil {
|
||||||
|
log.Printf("tick: dispatch morning routine %s: %v", cand.Routine.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// gatherMorningFacts reads the latest fact for every item's fact_key across
|
||||||
|
// all configured morning routines. Shared by fireMorningRoutines (nudge
|
||||||
|
// decision) and morningStatus (read-only query) so the two paths can never
|
||||||
|
// disagree about what evidence exists.
|
||||||
|
func (t *tickLoop) gatherMorningFacts(ctx context.Context) map[string]store.Fact {
|
||||||
|
keys := make(map[string]struct{})
|
||||||
|
for _, r := range t.morningRoutines {
|
||||||
|
for _, it := range r.Items {
|
||||||
|
keys[it.FactKey] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
facts := make(map[string]store.Fact, len(keys))
|
||||||
|
for k := range keys {
|
||||||
|
f, err := t.store.LatestFact(ctx, k)
|
||||||
|
if err == nil {
|
||||||
|
facts[k] = f
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != store.ErrNoFact {
|
||||||
|
log.Printf("tick: morning: latest fact %s: %v", k, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return facts
|
||||||
|
}
|
||||||
|
|
||||||
|
// morningStatus is the read-only "what's missing" query the web UI (and
|
||||||
|
// eventually a voice query) calls. Pure recompute over the current facts —
|
||||||
|
// no dedupe/nudge-time gating, unlike fireMorningRoutines: this answers
|
||||||
|
// "state right now," not "should we nag."
|
||||||
|
func (t *tickLoop) morningStatus(ctx context.Context, now time.Time) []ipc.MorningRoutineStatus {
|
||||||
|
if len(t.morningRoutines) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
facts := t.gatherMorningFacts(ctx)
|
||||||
|
out := make([]ipc.MorningRoutineStatus, 0, len(t.morningRoutines))
|
||||||
|
for _, r := range t.morningRoutines {
|
||||||
|
st := morning.Evaluate(r, facts, now)
|
||||||
|
done := make(map[string]bool, len(st.Completed))
|
||||||
|
for _, it := range st.Completed {
|
||||||
|
done[it.Key] = true
|
||||||
|
}
|
||||||
|
items := make([]ipc.MorningRoutineItem, len(r.Items))
|
||||||
|
for i, it := range r.Items {
|
||||||
|
items[i] = ipc.MorningRoutineItem{Key: it.Key, Label: it.Label, Done: done[it.Key]}
|
||||||
|
}
|
||||||
|
out = append(out, ipc.MorningRoutineStatus{
|
||||||
|
Name: r.Name,
|
||||||
|
Active: st.Active,
|
||||||
|
WindowStart: r.WindowStart,
|
||||||
|
WindowEnd: r.WindowEnd,
|
||||||
|
Items: items,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// tune — the feedback auto-tuner's impure step. runs on a slow cadence
|
// tune — the feedback auto-tuner's impure step. runs on a slow cadence
|
||||||
// (autotuneInterval, see run) so it doesn't write a fact every tick. for each
|
// (autotuneInterval, see run) so it doesn't write a fact every tick. for each
|
||||||
// rule:
|
// rule:
|
||||||
@@ -440,8 +545,9 @@ func (t *tickLoop) trace() *loop.TickTrace {
|
|||||||
// daemon's in-memory tick trace cache.
|
// daemon's in-memory tick trace cache.
|
||||||
type daemonAPI struct {
|
type daemonAPI struct {
|
||||||
ipc.CoreAPI
|
ipc.CoreAPI
|
||||||
getTrace func() *loop.TickTrace
|
getTrace func() *loop.TickTrace
|
||||||
chatFn func(ctx context.Context, text string) string
|
getMorningStatus func(ctx context.Context) []ipc.MorningRoutineStatus
|
||||||
|
chatFn func(ctx context.Context, text string) string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *daemonAPI) Chat(ctx context.Context, text string) (string, error) {
|
func (d *daemonAPI) Chat(ctx context.Context, text string) (string, error) {
|
||||||
@@ -459,6 +565,13 @@ func (d *daemonAPI) TickTrace(ctx context.Context) (ipc.TickTrace, error) {
|
|||||||
return toIPCTickTrace(*trace), nil
|
return toIPCTickTrace(*trace), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (d *daemonAPI) MorningStatus(ctx context.Context) ([]ipc.MorningRoutineStatus, error) {
|
||||||
|
if d.getMorningStatus == nil {
|
||||||
|
return nil, errors.New("mavend: morning status not available")
|
||||||
|
}
|
||||||
|
return d.getMorningStatus(ctx), nil
|
||||||
|
}
|
||||||
|
|
||||||
func toIPCTickTrace(t loop.TickTrace) ipc.TickTrace {
|
func toIPCTickTrace(t loop.TickTrace) ipc.TickTrace {
|
||||||
rules := make([]ipc.RuleTrace, len(t.RuleTraces))
|
rules := make([]ipc.RuleTrace, len(t.RuleTraces))
|
||||||
for i, r := range t.RuleTraces {
|
for i, r := range t.RuleTraces {
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ func newTestTickLoop(t *testing.T, st *store.Store, sink delivery.Sink, digestCf
|
|||||||
Nudges: st,
|
Nudges: st,
|
||||||
Reminders: st,
|
Reminders: st,
|
||||||
})
|
})
|
||||||
return newTickLoop(st, g, d, phraser.NewStub(), rules, time.Second, 5*time.Minute, 0, digestCfg, nil)
|
return newTickLoop(st, g, d, phraser.NewStub(), rules, time.Second, 5*time.Minute, 0, digestCfg, nil, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTickFiresRoutineWhenScheduleCrosses(t *testing.T) {
|
func TestTickFiresRoutineWhenScheduleCrosses(t *testing.T) {
|
||||||
@@ -63,7 +63,7 @@ func TestTickFiresRoutineWhenScheduleCrosses(t *testing.T) {
|
|||||||
sink := &fakeSink{}
|
sink := &fakeSink{}
|
||||||
d := delivery.NewDispatcher(delivery.Config{Voice: sink, Ntfy: sink, Telegram: sink, Nudges: st, Reminders: st})
|
d := delivery.NewDispatcher(delivery.Config{Voice: sink, Ntfy: sink, Telegram: sink, Nudges: st, Reminders: st})
|
||||||
rs := []routine.Routine{{Name: "morning", Cron: "0 12 * * *", Body: "полдень, время воды", Severity: 1}}
|
rs := []routine.Routine{{Name: "morning", Cron: "0 12 * * *", Body: "полдень, время воды", Severity: 1}}
|
||||||
tl := newTickLoop(st, g, d, phraser.NewStub(), rules, time.Second, 5*time.Minute, 0, nil, rs)
|
tl := newTickLoop(st, g, d, phraser.NewStub(), rules, time.Second, 5*time.Minute, 0, nil, rs, nil)
|
||||||
|
|
||||||
// first tick: seeds, does not fire the routine.
|
// first tick: seeds, does not fire the routine.
|
||||||
tl.tick(ctx, now)
|
tl.tick(ctx, now)
|
||||||
|
|||||||
+36
-3
@@ -63,6 +63,9 @@ var voiceHTML string
|
|||||||
//go:embed ecosystem.html
|
//go:embed ecosystem.html
|
||||||
var ecosystemHTML string
|
var ecosystemHTML string
|
||||||
|
|
||||||
|
//go:embed morning.html
|
||||||
|
var morningHTML string
|
||||||
|
|
||||||
// ── Ethos Workstation Shell ──
|
// ── Ethos Workstation Shell ──
|
||||||
//
|
//
|
||||||
// Two template pieces that wrap every page:
|
// Two template pieces that wrap every page:
|
||||||
@@ -95,6 +98,7 @@ var sidebarSections = []struct {
|
|||||||
{Label: "Notifications", URL: "/notifications", Key: "notifications"},
|
{Label: "Notifications", URL: "/notifications", Key: "notifications"},
|
||||||
{Label: "Reminders", URL: "/reminders", Key: "reminders"},
|
{Label: "Reminders", URL: "/reminders", Key: "reminders"},
|
||||||
{Label: "Routines", URL: "/routines", Key: "routines"},
|
{Label: "Routines", URL: "/routines", Key: "routines"},
|
||||||
|
{Label: "Morning", URL: "/morning", Key: "morning"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -169,6 +173,8 @@ func pageIcon(key string) string {
|
|||||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-calendar"/></svg>`
|
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-calendar"/></svg>`
|
||||||
case "routines":
|
case "routines":
|
||||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-repeat"/></svg>`
|
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-repeat"/></svg>`
|
||||||
|
case "morning":
|
||||||
|
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-calendar"/></svg>`
|
||||||
case "chat":
|
case "chat":
|
||||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-message"/></svg>`
|
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-message"/></svg>`
|
||||||
case "voice":
|
case "voice":
|
||||||
@@ -199,6 +205,8 @@ func pageTitle(key string) string {
|
|||||||
return "Reminders"
|
return "Reminders"
|
||||||
case "routines":
|
case "routines":
|
||||||
return "Routines"
|
return "Routines"
|
||||||
|
case "morning":
|
||||||
|
return "Morning Routines"
|
||||||
case "chat":
|
case "chat":
|
||||||
return "Chat"
|
return "Chat"
|
||||||
case "voice":
|
case "voice":
|
||||||
@@ -291,6 +299,12 @@ var dashTmpl = template.Must(template.New("dash").Funcs(shellFuncs()).Parse(shel
|
|||||||
// human surface is here (they ship no web UI of their own).
|
// human surface is here (they ship no web UI of their own).
|
||||||
var ecosystemTmpl = template.Must(template.New("ecosystem").Funcs(shellFuncs()).Parse(shellTopHTML + ecosystemHTML + shellBottomHTML))
|
var ecosystemTmpl = template.Must(template.New("ecosystem").Funcs(shellFuncs()).Parse(shellTopHTML + ecosystemHTML + shellBottomHTML))
|
||||||
|
|
||||||
|
// morningTmpl — read-only view of today's checklist state per configured
|
||||||
|
// morning routine (internal/morning). Same shape as trace.html: a plain
|
||||||
|
// server-rendered page, refreshed on reload — no live-update loop, since
|
||||||
|
// checklist state changes on the scale of minutes, not seconds.
|
||||||
|
var morningTmpl = template.Must(template.New("morning").Funcs(shellFuncs()).Parse(shellTopHTML + morningHTML + shellBottomHTML))
|
||||||
|
|
||||||
func noCache(h http.Handler) http.Handler {
|
func noCache(h http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||||
@@ -384,6 +398,9 @@ func main() {
|
|||||||
mux.HandleFunc("/routines", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/routines", func(w http.ResponseWriter, r *http.Request) {
|
||||||
handleRoutines(w, r, core)
|
handleRoutines(w, r, core)
|
||||||
})
|
})
|
||||||
|
mux.HandleFunc("/morning", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
handleMorning(w, r, core)
|
||||||
|
})
|
||||||
ecoURLsCfg := ecoURLs{nexus: *nexusURL, praxis: *praxisURL, hexis: *hexisURL}
|
ecoURLsCfg := ecoURLs{nexus: *nexusURL, praxis: *praxisURL, hexis: *hexisURL}
|
||||||
mux.HandleFunc("/ecosystem", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/ecosystem", func(w http.ResponseWriter, r *http.Request) {
|
||||||
handleEcosystem(w, r, ecoURLsCfg)
|
handleEcosystem(w, r, ecoURLsCfg)
|
||||||
@@ -829,6 +846,24 @@ func handleTrace(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func handleMorning(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||||
|
if core == nil {
|
||||||
|
http.Error(w, "morning disabled (no -core)", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := r.Context()
|
||||||
|
status, err := core.MorningStatus(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("morning: %v", err)
|
||||||
|
http.Error(w, "core read failed", http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
if err := morningTmpl.Execute(w, status); err != nil {
|
||||||
|
log.Printf("morning render: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func handleVoice(w http.ResponseWriter, r *http.Request) {
|
func handleVoice(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
if err := voiceTmpl.Execute(w, nil); err != nil {
|
if err := voiceTmpl.Execute(w, nil); err != nil {
|
||||||
@@ -901,9 +936,7 @@ func handleTools(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, sessi
|
|||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
var msg string
|
var msg string
|
||||||
if r.Method == http.MethodPost {
|
if r.Method == http.MethodPost {
|
||||||
// nil session ⇒ WebAuthn not configured; step-up gate not applicable
|
if !stepUpOK(session, requireStepUp) {
|
||||||
// (see wiring in main — asserting would be impossible, not just unmet).
|
|
||||||
if session != nil && !session.IsStepUp() {
|
|
||||||
http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden)
|
http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{{template "shellTop" "morning"}}
|
||||||
|
<h1>Morning Routines</h1>
|
||||||
|
{{if not .}}
|
||||||
|
<div class=hint>no morning routines configured</div>
|
||||||
|
{{else}}
|
||||||
|
{{range .}}
|
||||||
|
<div class="mb-4">
|
||||||
|
<div><strong>{{.Name}}</strong>
|
||||||
|
<span class={{if .Active}}green{{else}}gray{{end}}>{{if .Active}}active now{{else}}outside window{{end}}</span>
|
||||||
|
<span class=hint>{{.WindowStart}}–{{.WindowEnd}}</span>
|
||||||
|
</div>
|
||||||
|
<div class=scroll><table class=mono>
|
||||||
|
<tr><th>item<th>status</tr>
|
||||||
|
{{range .Items}}<tr>
|
||||||
|
<td>{{.Label}}</td>
|
||||||
|
<td class={{if .Done}}green{{else}}red{{end}}>{{if .Done}}done{{else}}missing{{end}}</td>
|
||||||
|
</tr>{{end}}
|
||||||
|
</table></div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
{{template "shellBottom"}}
|
||||||
|
</html>
|
||||||
@@ -391,6 +391,9 @@ func (r *recordingAPI) ListReminders(_ context.Context, _ int) ([]ipc.Reminder,
|
|||||||
func (r *recordingAPI) TickTrace(_ context.Context) (ipc.TickTrace, error) {
|
func (r *recordingAPI) TickTrace(_ context.Context) (ipc.TickTrace, error) {
|
||||||
return ipc.TickTrace{}, nil
|
return ipc.TickTrace{}, nil
|
||||||
}
|
}
|
||||||
|
func (r *recordingAPI) MorningStatus(_ context.Context) ([]ipc.MorningRoutineStatus, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
func (r *recordingAPI) RecordNudge(_ context.Context, _, _, _ string, _ time.Time) (int64, error) {
|
func (r *recordingAPI) RecordNudge(_ context.Context, _, _, _ string, _ time.Time) (int64, error) {
|
||||||
return 1, nil
|
return 1, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import (
|
|||||||
|
|
||||||
"github.com/kami/maven/internal/delivery/ntfysink"
|
"github.com/kami/maven/internal/delivery/ntfysink"
|
||||||
"github.com/kami/maven/internal/delivery/telegramsink"
|
"github.com/kami/maven/internal/delivery/telegramsink"
|
||||||
|
"github.com/kami/maven/internal/morning"
|
||||||
"github.com/robfig/cron/v3"
|
"github.com/robfig/cron/v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -134,6 +135,11 @@ type Config struct {
|
|||||||
// distinction from reminders (user-stated) and care rules (world-state).
|
// distinction from reminders (user-stated) and care rules (world-state).
|
||||||
Routines []RoutineConfig `json:"routines,omitempty"`
|
Routines []RoutineConfig `json:"routines,omitempty"`
|
||||||
|
|
||||||
|
// MorningRoutines — daily checklists (medicine, water, pets, ...) checked
|
||||||
|
// once near the end of a time window instead of firing one reminder per
|
||||||
|
// item. See internal/morning for the evaluation engine. Empty ⇒ disabled.
|
||||||
|
MorningRoutines []MorningRoutineConfig `json:"morning_routines,omitempty"`
|
||||||
|
|
||||||
// Praxis — the ecosystem attention-state service. When configured, maven
|
// Praxis — the ecosystem attention-state service. When configured, maven
|
||||||
// calls the Praxis HTTP tools API for attention listing and item lifecycle.
|
// calls the Praxis HTTP tools API for attention listing and item lifecycle.
|
||||||
// Maven never touches Praxis's database directly (ecosystem invariant: no
|
// Maven never touches Praxis's database directly (ecosystem invariant: no
|
||||||
@@ -181,6 +187,28 @@ type RoutineConfig struct {
|
|||||||
Severity int `json:"severity,omitempty"`
|
Severity int `json:"severity,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MorningRoutineConfig — one daily checklist. WindowStart/WindowEnd/NudgeAt
|
||||||
|
// are "HH:MM" local time; NudgeAt empty defaults to WindowEnd. Weekdays are
|
||||||
|
// 0=Sunday..6=Saturday; empty means every day (set two routines under
|
||||||
|
// different names for weekday/weekend variants).
|
||||||
|
type MorningRoutineConfig struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Weekdays []int `json:"weekdays,omitempty"`
|
||||||
|
WindowStart string `json:"window_start"`
|
||||||
|
WindowEnd string `json:"window_end"`
|
||||||
|
NudgeAt string `json:"nudge_at,omitempty"`
|
||||||
|
Severity int `json:"severity,omitempty"`
|
||||||
|
Items []MorningRoutineItemConfig `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MorningRoutineItemConfig — one checklist entry. FactKey is the fact whose
|
||||||
|
// presence within the window counts as completion evidence.
|
||||||
|
type MorningRoutineItemConfig struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
FactKey string `json:"fact_key"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
}
|
||||||
|
|
||||||
// QuietHoursConfig — a recurring daily quiet-window. Times are local to the
|
// QuietHoursConfig — a recurring daily quiet-window. Times are local to the
|
||||||
// server's wall clock. A window crossing midnight (Start > End) is handled:
|
// server's wall clock. A window crossing midnight (Start > End) is handled:
|
||||||
// "23:00"-"08:00" means quiet from 23:00 to 08:00 the next day.
|
// "23:00"-"08:00" means quiet from 23:00 to 08:00 the next day.
|
||||||
@@ -456,6 +484,13 @@ func (c *Config) applyDefaults() {
|
|||||||
c.Routines[i].Severity = 1
|
c.Routines[i].Severity = 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// morning routines: same safe-floor default as cron routines.
|
||||||
|
for i := range c.MorningRoutines {
|
||||||
|
if c.MorningRoutines[i].Severity == 0 {
|
||||||
|
c.MorningRoutines[i].Severity = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) validate() error {
|
func (c *Config) validate() error {
|
||||||
@@ -488,9 +523,46 @@ func (c *Config) validate() error {
|
|||||||
return fmt.Errorf("routine %q: bad cron %q: %w", r.Name, r.Cron, err)
|
return fmt.Errorf("routine %q: bad cron %q: %w", r.Name, r.Cron, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if len(c.MorningRoutines) > 0 {
|
||||||
|
if err := morning.Validate(morningRoutinesFromConfig(c.MorningRoutines)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// morningRoutinesFromConfig maps the config's morning-routine blocks to the
|
||||||
|
// engine type. Shared with the daemon so config validation and daemon wiring
|
||||||
|
// can never drift on the mapping.
|
||||||
|
func morningRoutinesFromConfig(mc []MorningRoutineConfig) []morning.Routine {
|
||||||
|
out := make([]morning.Routine, len(mc))
|
||||||
|
for i, r := range mc {
|
||||||
|
items := make([]morning.Item, len(r.Items))
|
||||||
|
for j, it := range r.Items {
|
||||||
|
items[j] = morning.Item{Key: it.Key, FactKey: it.FactKey, Label: it.Label}
|
||||||
|
}
|
||||||
|
weekdays := make([]time.Weekday, len(r.Weekdays))
|
||||||
|
for j, w := range r.Weekdays {
|
||||||
|
weekdays[j] = time.Weekday(w)
|
||||||
|
}
|
||||||
|
out[i] = morning.Routine{
|
||||||
|
Name: r.Name,
|
||||||
|
Weekdays: weekdays,
|
||||||
|
WindowStart: r.WindowStart,
|
||||||
|
WindowEnd: r.WindowEnd,
|
||||||
|
NudgeAt: r.NudgeAt,
|
||||||
|
Severity: r.Severity,
|
||||||
|
Items: items,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// MorningRoutinesFromConfig is the exported form daemon wiring uses.
|
||||||
|
func MorningRoutinesFromConfig(mc []MorningRoutineConfig) []morning.Routine {
|
||||||
|
return morningRoutinesFromConfig(mc)
|
||||||
|
}
|
||||||
|
|
||||||
// DBEncryptionKey resolves the at-rest encryption key: DBKeyEnv (if set) wins
|
// DBEncryptionKey resolves the at-rest encryption key: DBKeyEnv (if set) wins
|
||||||
// over DBKeyB64. Returns (nil, nil) when neither is set — the caller then opens
|
// over DBKeyB64. Returns (nil, nil) when neither is set — the caller then opens
|
||||||
// a plaintext store. A configured-but-invalid key is an error (fail closed,
|
// a plaintext store. A configured-but-invalid key is an error (fail closed,
|
||||||
|
|||||||
@@ -292,6 +292,13 @@ type CoreAPI interface {
|
|||||||
// persisted — it's a daemon-level cache).
|
// persisted — it's a daemon-level cache).
|
||||||
TickTrace(ctx context.Context) (TickTrace, error)
|
TickTrace(ctx context.Context) (TickTrace, error)
|
||||||
|
|
||||||
|
// MorningStatus returns each configured morning routine's current
|
||||||
|
// checklist state (see internal/morning): active today/now, which items
|
||||||
|
// are done, which are still missing. The store adapter returns an error
|
||||||
|
// (morning routines are daemon-config, not persisted) — same shape as
|
||||||
|
// TickTrace.
|
||||||
|
MorningStatus(ctx context.Context) ([]MorningRoutineStatus, error)
|
||||||
|
|
||||||
// Chat routes a text utterance through the reactive handler's core path
|
// Chat routes a text utterance through the reactive handler's core path
|
||||||
// (router → dialogue → action → replier) and returns the reply text.
|
// (router → dialogue → action → replier) and returns the reply text.
|
||||||
// No audio or stt/tts — for text channels (mavweb, telegram).
|
// No audio or stt/tts — for text channels (mavweb, telegram).
|
||||||
@@ -329,6 +336,22 @@ type TickTrace struct {
|
|||||||
Rules []RuleTrace `json:"rules"`
|
Rules []RuleTrace `json:"rules"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MorningRoutineItem — one checklist entry's current state.
|
||||||
|
type MorningRoutineItem struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Done bool `json:"done"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MorningRoutineStatus — one routine's checklist state right now.
|
||||||
|
type MorningRoutineStatus struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Active bool `json:"active"`
|
||||||
|
WindowStart string `json:"window_start"`
|
||||||
|
WindowEnd string `json:"window_end"`
|
||||||
|
Items []MorningRoutineItem `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
// storeEncryptionKeyReq — passkey credential public key for wrapping the store
|
// storeEncryptionKeyReq — passkey credential public key for wrapping the store
|
||||||
// encryption key at enrollment time. Called by mavweb after RegisterFinish.
|
// encryption key at enrollment time. Called by mavweb after RegisterFinish.
|
||||||
type storeEncryptionKeyReq struct {
|
type storeEncryptionKeyReq struct {
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ var readOnlyMethods = map[Method]bool{
|
|||||||
MethodListTools: true,
|
MethodListTools: true,
|
||||||
MethodListProposedRoutines: true,
|
MethodListProposedRoutines: true,
|
||||||
MethodTickTrace: true,
|
MethodTickTrace: true,
|
||||||
|
MethodMorningStatus: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dial connects to a core socket at path and returns a Client. The module
|
// Dial connects to a core socket at path and returns a Client. The module
|
||||||
@@ -445,6 +446,14 @@ func (c *Client) TickTrace(ctx context.Context) (TickTrace, error) {
|
|||||||
return t, nil
|
return t, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Client) MorningStatus(ctx context.Context) ([]MorningRoutineStatus, error) {
|
||||||
|
var s []MorningRoutineStatus
|
||||||
|
if err := c.call(ctx, MethodMorningStatus, nil, &s); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) RevertFact(ctx context.Context, key string) (int64, error) {
|
func (c *Client) RevertFact(ctx context.Context, key string) (int64, error) {
|
||||||
var result struct {
|
var result struct {
|
||||||
NewID int64 `json:"new_id"`
|
NewID int64 `json:"new_id"`
|
||||||
|
|||||||
@@ -493,6 +493,9 @@ func (a *chatTestAPI) RevertFact(ctx context.Context, key string) (int64, error)
|
|||||||
func (a *chatTestAPI) TickTrace(ctx context.Context) (TickTrace, error) {
|
func (a *chatTestAPI) TickTrace(ctx context.Context) (TickTrace, error) {
|
||||||
return TickTrace{}, ErrUnknownMethod
|
return TickTrace{}, ErrUnknownMethod
|
||||||
}
|
}
|
||||||
|
func (a *chatTestAPI) MorningStatus(ctx context.Context) ([]MorningRoutineStatus, error) {
|
||||||
|
return nil, ErrUnknownMethod
|
||||||
|
}
|
||||||
func (a *chatTestAPI) Chat(ctx context.Context, text string) (string, error) {
|
func (a *chatTestAPI) Chat(ctx context.Context, text string) (string, error) {
|
||||||
if text == "привет" {
|
if text == "привет" {
|
||||||
return "и тебе привет!", nil
|
return "и тебе привет!", nil
|
||||||
|
|||||||
@@ -207,6 +207,10 @@ func (a *storeAPI) TickTrace(ctx context.Context) (TickTrace, error) {
|
|||||||
return TickTrace{}, errors.New("store: tick trace not available via direct store API")
|
return TickTrace{}, errors.New("store: tick trace not available via direct store API")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *storeAPI) MorningStatus(ctx context.Context) ([]MorningRoutineStatus, error) {
|
||||||
|
return nil, errors.New("store: morning status not available via direct store API")
|
||||||
|
}
|
||||||
|
|
||||||
func (a *storeAPI) ListTools(ctx context.Context, status string) ([]Tool, error) {
|
func (a *storeAPI) ListTools(ctx context.Context, status string) ([]Tool, error) {
|
||||||
ts, err := a.s.ListTools(ctx, status)
|
ts, err := a.s.ListTools(ctx, status)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -801,6 +805,13 @@ func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, er
|
|||||||
}
|
}
|
||||||
return marshalResult(t), nil
|
return marshalResult(t), nil
|
||||||
|
|
||||||
|
case MethodMorningStatus:
|
||||||
|
s, err := api.MorningStatus(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return marshalResult(s), nil
|
||||||
|
|
||||||
case MethodAssertStepUp:
|
case MethodAssertStepUp:
|
||||||
if s.StepUp != nil {
|
if s.StepUp != nil {
|
||||||
return marshalResult(nil), s.StepUp(ctx)
|
return marshalResult(nil), s.StepUp(ctx)
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ const (
|
|||||||
MethodDismissProposedRoutine Method = "dismiss_proposed_routine"
|
MethodDismissProposedRoutine Method = "dismiss_proposed_routine"
|
||||||
MethodRevertFact Method = "revert_fact"
|
MethodRevertFact Method = "revert_fact"
|
||||||
MethodTickTrace Method = "tick_trace"
|
MethodTickTrace Method = "tick_trace"
|
||||||
|
MethodMorningStatus Method = "morning_status"
|
||||||
MethodChat Method = "chat"
|
MethodChat Method = "chat"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user