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. // More counts what Next dropped off the end, so the sentence can say that more // remains instead of implying the day ends after the third line. type Plan struct { Date time.Time Items []PlanEntry Rest bool More int } // 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 } // NextSpoken — how many entries "что дальше?" reads aloud. Three, for the same // reason the feed reads three headlines: the answer is spoken once and cannot be // scrolled back, and a list longer than a breath is not an answer, it is a // recital. Asked at 04:45 on a day with 43 entries, the trim below removes // nothing — everything is still ahead — so the cap is what makes "дальше" mean // next rather than today (V-618). const NextSpoken = 3 // 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. // // Strictly after: an entry at exactly now is the thing happening, not the thing // next. func (p Plan) After(now time.Time) Plan { out := Plan{Date: p.Date, Rest: true} for _, it := range p.Items { if !it.At.After(now) { continue } out.Items = append(out.Items, it) } return out } // Next is After with a spoken cap — what "что дальше?" actually answers with. // The overflow is counted rather than dropped, because "дальше: 10:00 …" with // forty entries hidden behind it is a false picture of the day. func (p Plan) Next(now time.Time, n int) Plan { out := p.After(now) if n > 0 && len(out.Items) > n { out.More = len(out.Items) - n out.Items = out.Items[:n] } 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. // // Every hour is read in the plan's own zone — Date's, which BuildPlan sets from // the asking clock. Printed raw, an hour read whatever zone its instant arrived // in: an event or a reminder comes off the store as UTC, while a checklist line // is built local, so one spoken sentence named two zones. This is the voice // path, so that is what the owner heard (V-614); the same defect on the two web // pages was V-612. func (p Plan) FormatRU() string { zone := p.Date.Location() 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.In(zone).Format("15:04"), it.Text) if it.Uncertain { line = say.S(say.PlanUncertain, map[string]string{"line": line}) } parts[i] = line } items := strings.Join(parts, "; ") // The rest of the day is a different sentence, not a shorter day plan. It // carries no date — he asked what is next, and he knows which day he is in — // and it says out loud when there is more behind the cap. if p.Rest { if p.More > 0 { return say.S(say.PlanNextMore, map[string]string{ "items": items, "n": fmt.Sprint(p.More), "word": say.CountWord(p.More, "дело", "дела", "дел"), }) } return say.S(say.PlanNext, map[string]string{"items": items}) } return say.S(say.PlanDay, map[string]string{ "date": p.Date.Format("02.01.2006"), "items": items, }) }