// mavend/tick_morning.go — the morning checklist and the day plan. // // Split out of tick.go, move-only (Vikunja #422). One window per configured // routine, nudging once at the end for what is still open, plus the read // surfaces /morning renders. package main import ( "context" "fmt" "log" "strings" "time" "github.com/kami/maven/internal/calendar" "github.com/kami/maven/internal/delivery" "github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/loop" "github.com/kami/maven/internal/morning" "github.com/kami/maven/internal/store" ) // 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) { body := morningNudgeBody(cand) 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) } } } // morningNudgeBody words the one message a routine gets per day. Required // items are what she says was not done; optional ones follow, worded as // something he could still do rather than something he owes (Vikunja #473). // Operator text, not phrased by the model, for the same reason it always was: // a checklist item must not be invented. func morningNudgeBody(cand morning.Candidate) string { labels := func(items []morning.Item) string { out := make([]string, len(items)) for i, it := range items { out[i] = it.Label } return strings.Join(out, ", ") } body := fmt.Sprintf("%s: не сделано — %s", cand.Routine.Name, labels(morning.Required(cand.Missing))) if opt := morning.OptionalOnly(cand.Missing); len(opt) > 0 { body += fmt.Sprintf(". если будет время — %s", labels(opt)) } return body } // 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 } // dayPlan is the read-only "what does today hold" query (Vikunja #128). It is // the impure half of morning.BuildPlan: it reads the calendar events, the // pending reminders and the checklist facts, and the pure builder orders them. // // It never dispatches. Asking for the plan is a query like any other; the only // unprompted delivery in maven stays with the morning nudge and the // dispatcher's policy. func (t *tickLoop) dayPlan(ctx context.Context, now time.Time) ipc.DayPlan { y, m, d := now.Date() dayStart := time.Date(y, m, d, 0, 0, 0, 0, now.Location()) dayEnd := dayStart.AddDate(0, 0, 1) var events []morning.PlanEntry facts, err := t.store.CalendarEvents(ctx, dayStart, dayEnd) if err != nil { log.Printf("tick: day plan: calendar events: %v", err) } for _, f := range facts { events = append(events, morning.PlanEntry{ At: f.Ts, // The plan prints the hour itself, so the "@ 14:00-14:30" tail the // fact value carries would say it twice. Text: calendar.FactSummary(f.Value), Kind: morning.PlanEvent, // Provenance below a calendar read (an ambient relay, #126) is // hedged rather than recited as fact. Uncertain: f.Confidence < 1.0, }) } var reminders []morning.PlanEntry rems, err := t.store.PendingReminders(ctx, dayStart, dayEnd) if err != nil { log.Printf("tick: day plan: pending reminders: %v", err) } for _, r := range rems { if r.Status != store.ReminderPending { continue } fire := r.NextFireTs if fire.IsZero() { fire = r.FireTs } reminders = append(reminders, morning.PlanEntry{ At: fire, Text: r.Text(), Kind: morning.PlanReminder, }) } var checklistFacts map[string]store.Fact if len(t.morningRoutines) > 0 { checklistFacts = t.gatherMorningFacts(ctx) } plan := morning.BuildPlan(t.morningRoutines, checklistFacts, events, reminders, now) out := ipc.DayPlan{Date: plan.Date, Spoken: plan.FormatRU()} out.Items = make([]ipc.DayPlanItem, len(plan.Items)) for i, it := range plan.Items { out.Items[i] = ipc.DayPlanItem{ At: it.At, Text: it.Text, Kind: string(it.Kind), Uncertain: it.Uncertain, } } return out }