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, "; "), }) }