b3c2fad4ec
Four defects in the plan, all of them in what it reads or how it prints it. The checklist line was keyed on Status.Active, which Evaluate reports only inside the window, so a morning routine skipped and asked about at 14:00 said nothing. Outstanding answers the question the plan asks, "what did today still not get done", and the line stays placed at the nudge time so it sorts to the top of the day. Nothing before the window opens counts, so 06:00 is not a complaint. The event text kept the "@ 14:00-14:30" tail FactValue writes, next to a line that prints the hour itself, so every event said its time twice. Reminders came off ListReminders, which orders by creation, so the 500 row cap dropped a reminder stated long ago for today and kept one stated this morning for next year. PendingReminders bounds by fire time instead. The pending filter used a string literal, one typo from matching nothing. After now marks the plan it trimmed. "что дальше?" past the last item answered "на 03.08.2026 ничего не запланировано", which denies a day he just lived through. The surface the plan belongs on is still open, tracked as Vikunja #431; the comment in actions_query.go points at it. Found in review of #58.
181 lines
5.9 KiB
Go
181 lines
5.9 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. 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 "на сегодня больше ничего не запланировано."
|
|
}
|
|
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, "; "))
|
|
}
|