42d7a39c49
The three callers now read their sentences out of summary_ru_v1.json: the
plan lines in morning.Plan.FormatRU, the list and reason words in
tasks.FormatRU, and the habit readouts in memory.Profile.
Two behaviour_test assertions moved from substring to say.IsS, because the
habit gaps have variants now and a substring pins one of them. The
"по {day} у тебя обычно" variant was dropped on sight: the activities are
verbs, so it read "у тебя обычно тренируешься".
The persona scorer covers the family, and a new test asserts every gap
variant still says she has not seen enough rather than that he has nothing.
185 lines
6.0 KiB
Go
185 lines
6.0 KiB
Go
package morning
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/say"
|
|
"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. Rest marks a
|
|
// plan trimmed by After, which changes what an empty one means: a day with
|
|
// nothing on it and a day whose last item has passed are different answers.
|
|
type Plan struct {
|
|
Date time.Time
|
|
Items []PlanEntry
|
|
Rest bool
|
|
}
|
|
|
|
// 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, has not opened yet, or is already
|
|
// complete contributes nothing: the plan says what is left, not what was done.
|
|
//
|
|
// A closed window still counts. Asked at 14:00 with the morning routine
|
|
// unfinished, the plan used to say nothing about it, because Evaluate reports
|
|
// Active only inside the window. What he skipped is the one thing the plan can
|
|
// tell him that the calendar cannot, and the entry sorts to its nudge time, not
|
|
// to the moment of asking.
|
|
func checklistEntries(routines []Routine, facts map[string]store.Fact, now time.Time) []PlanEntry {
|
|
var out []PlanEntry
|
|
for _, r := range routines {
|
|
missing := Outstanding(r, facts, now)
|
|
if len(missing) == 0 {
|
|
continue
|
|
}
|
|
labels := make([]string, 0, len(missing))
|
|
for _, it := range 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, Rest: true}
|
|
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 {
|
|
// "что дальше?" after the last item of the day. The day was not empty,
|
|
// it is over, and saying it was empty is a false statement about a day
|
|
// he just lived.
|
|
if p.Rest {
|
|
return say.S(say.PlanRestEmpty, nil)
|
|
}
|
|
return say.S(say.PlanDayEmpty, map[string]string{"date": 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 = say.S(say.PlanUncertain, map[string]string{"line": line})
|
|
}
|
|
parts[i] = line
|
|
}
|
|
return say.S(say.PlanDay, map[string]string{
|
|
"date": p.Date.Format("02.01.2006"),
|
|
"items": strings.Join(parts, "; "),
|
|
})
|
|
}
|