ed9bdd5e09
The plan answers "какие планы на сегодня?" by putting one day in order: calendar events (with #126's ambient provenance carried through and hedged), pending reminders, and one line per morning routine that still has items outstanding. "что дальше?" trims what has already passed. It lives in internal/morning, not in a parallel system, because it is the same question the checklist asks at a different scale — the routine knows what is missing from a window, the plan knows what the whole day holds, and both read the same facts and the same idea of "today". BuildPlan is pure; tickLoop.dayPlan is the impure half that reads the store. It is not a nag. Nothing here fires, schedules or announces: the plan is built only when asked, over IPC (day_plan) or on the existing /morning page. Unprompted delivery stays with the morning nudge and the dispatcher's policy. The query source sits before "calendar" in querySources because both match "…на сегодня" and the plan's matcher is the more specific one; IsDayPlanQuery matches whole words so "планёрка" (a meeting) is not read as a request for the plan, and refuses any utterance naming another day, since the plan is built for the clock's own day only. Verified: make build and make test both exit 0; new tests cover plan ordering, the checklist-only-what-is-left rule, other-day rejection, the RU rendering against the persona checks, rest-of-day trimming, the source ordering, and the matcher's refusals.
166 lines
5.1 KiB
Go
166 lines
5.1 KiB
Go
package morning
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/store"
|
|
)
|
|
|
|
// The day plan (Vikunja #128).
|
|
//
|
|
// It lives here, with the morning routine engine, because it is the same
|
|
// question asked at a different scale: the routine knows what is still missing
|
|
// from a window, the plan knows what the whole day holds. A parallel system
|
|
// would have to re-read the same facts and re-decide what "today" means.
|
|
//
|
|
// It is pure, like the rest of this package: the daemon reads the calendar,
|
|
// the reminders and the checklist facts, and BuildPlan puts them in order.
|
|
//
|
|
// It is also NOT a nag. A plan she can recite when asked is the whole feature;
|
|
// nothing here fires, schedules or announces. Unprompted delivery stays with
|
|
// the existing morning nudge and the dispatcher's policy.
|
|
|
|
// PlanKind — where a plan line came from. It survives into the reply and the
|
|
// web view because the three read differently: an event is something happening
|
|
// to the owner, a reminder is something he asked for, a checklist item is
|
|
// something he has not done yet.
|
|
type PlanKind string
|
|
|
|
const (
|
|
PlanEvent PlanKind = "event"
|
|
PlanReminder PlanKind = "reminder"
|
|
PlanChecklist PlanKind = "checklist"
|
|
)
|
|
|
|
// PlanEntry — one timed thing on the day, as the daemon read it out of the
|
|
// store. Text is rendered verbatim; the plan does not rephrase.
|
|
//
|
|
// Uncertain marks provenance below a full-confidence read — a work meeting
|
|
// relayed off a phone notification (#126). It travels through to the reply so
|
|
// she hedges instead of reciting a guess as fact.
|
|
type PlanEntry struct {
|
|
At time.Time
|
|
Text string
|
|
Kind PlanKind
|
|
Uncertain bool
|
|
}
|
|
|
|
// Plan — the ordered day. Date is the calendar day it describes.
|
|
type Plan struct {
|
|
Date time.Time
|
|
Items []PlanEntry
|
|
}
|
|
|
|
// BuildPlan orders everything known about the day Now falls on: calendar
|
|
// events, pending reminders, and one line per morning routine that still has
|
|
// unfinished items.
|
|
//
|
|
// Entries outside that calendar day are dropped — a plan for today that
|
|
// includes tomorrow's meeting is wrong in a way that is worse than terse.
|
|
// Ordering is by time, then by kind, then by text, so the same day always reads
|
|
// the same way.
|
|
func BuildPlan(routines []Routine, facts map[string]store.Fact, events, reminders []PlanEntry, now time.Time) Plan {
|
|
y, m, d := now.Date()
|
|
dayStart := time.Date(y, m, d, 0, 0, 0, 0, now.Location())
|
|
dayEnd := dayStart.AddDate(0, 0, 1)
|
|
|
|
p := Plan{Date: dayStart}
|
|
for _, group := range [][]PlanEntry{events, reminders} {
|
|
for _, e := range group {
|
|
at := e.At.In(now.Location())
|
|
if at.Before(dayStart) || !at.Before(dayEnd) {
|
|
continue
|
|
}
|
|
if strings.TrimSpace(e.Text) == "" {
|
|
continue
|
|
}
|
|
e.At = at
|
|
p.Items = append(p.Items, e)
|
|
}
|
|
}
|
|
p.Items = append(p.Items, checklistEntries(routines, facts, now)...)
|
|
|
|
sort.SliceStable(p.Items, func(i, j int) bool {
|
|
a, b := p.Items[i], p.Items[j]
|
|
if !a.At.Equal(b.At) {
|
|
return a.At.Before(b.At)
|
|
}
|
|
if a.Kind != b.Kind {
|
|
return a.Kind < b.Kind
|
|
}
|
|
return a.Text < b.Text
|
|
})
|
|
return p
|
|
}
|
|
|
|
// checklistEntries renders one line per routine with work left in it, placed at
|
|
// the routine's nudge time — where the checklist actually matters in the day.
|
|
// A routine that does not apply today, is not in its window, or is already
|
|
// complete contributes nothing: the plan says what is left, not what was done.
|
|
func checklistEntries(routines []Routine, facts map[string]store.Fact, now time.Time) []PlanEntry {
|
|
var out []PlanEntry
|
|
for _, r := range routines {
|
|
st := Evaluate(r, facts, now)
|
|
if !st.Active || len(st.Missing) == 0 {
|
|
continue
|
|
}
|
|
labels := make([]string, 0, len(st.Missing))
|
|
for _, it := range st.Missing {
|
|
label := it.Label
|
|
if label == "" {
|
|
label = it.Key
|
|
}
|
|
labels = append(labels, label)
|
|
}
|
|
at := r.NudgeAt
|
|
if at == "" {
|
|
at = r.WindowEnd
|
|
}
|
|
when, ok := todayAt(at, now)
|
|
if !ok {
|
|
continue
|
|
}
|
|
out = append(out, PlanEntry{
|
|
At: when,
|
|
Text: fmt.Sprintf("%s — осталось: %s", r.Name, strings.Join(labels, ", ")),
|
|
Kind: PlanChecklist,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// After returns the part of the plan that has not happened yet — the answer to
|
|
// "что дальше?" as opposed to "какие планы на сегодня?". The Date is kept, so an
|
|
// empty result still knows which day it is empty for.
|
|
func (p Plan) After(now time.Time) Plan {
|
|
out := Plan{Date: p.Date}
|
|
for _, it := range p.Items {
|
|
if it.At.Before(now) {
|
|
continue
|
|
}
|
|
out.Items = append(out.Items, it)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// FormatRU renders the plan as maven says it. Feminine self-reference,
|
|
// informal address, no pet names — and no exhortation: she reads the day back,
|
|
// she does not tell him to get on with it.
|
|
func (p Plan) FormatRU() string {
|
|
if len(p.Items) == 0 {
|
|
return fmt.Sprintf("на %s ничего не запланировано.", p.Date.Format("02.01.2006"))
|
|
}
|
|
parts := make([]string, len(p.Items))
|
|
for i, it := range p.Items {
|
|
line := fmt.Sprintf("%s — %s", it.At.Format("15:04"), it.Text)
|
|
if it.Uncertain {
|
|
line = "похоже, " + line
|
|
}
|
|
parts[i] = line
|
|
}
|
|
return fmt.Sprintf("план на %s: %s.", p.Date.Format("02.01.2006"), strings.Join(parts, "; "))
|
|
}
|