5447f08c06
Both Due functions key their last-fired map by routine name, so two routines sharing a name took turns suppressing each other and one of them never fired. Validate now rejects a duplicate name in either package. parseHHMM checked the digits arithmetically, which let a stray character cancel out: window_start of 2 :00 loaded as 04:00 and passed the validation that exists to catch that typo. Each of the four positions is now checked as a digit, which makes the negative bounds unreachable and they are gone. Folded the three copies of the unevidenced-item loop in Evaluate, Outstanding and Due into one helper.
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 {
|
|
left := Outstanding(r, facts, now)
|
|
if len(left) == 0 {
|
|
continue
|
|
}
|
|
labels := make([]string, 0, len(left))
|
|
for _, it := range left {
|
|
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, "; "),
|
|
})
|
|
}
|