diff --git a/cmd/mavend/actions_query.go b/cmd/mavend/actions_query.go index 173a456..e855f33 100644 --- a/cmd/mavend/actions_query.go +++ b/cmd/mavend/actions_query.go @@ -5,10 +5,12 @@ import ( "errors" "fmt" "log" + "strings" "time" "github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/memory" + "github.com/kami/maven/internal/morning" "github.com/kami/maven/internal/router" "github.com/kami/maven/internal/weather" ) @@ -45,6 +47,10 @@ type querySource struct { // line here plus its method; where you put the line is the whole decision. var querySources = []querySource{ {"fact-by-key", (*reactiveHandler).queryFactByKey}, + // Before "calendar" on purpose: both match "…на сегодня", and the plan is + // the more specific ask (its matcher requires a plan word), so the calendar + // listing would otherwise swallow it. + {"day-plan", (*reactiveHandler).queryDayPlan}, {"calendar", (*reactiveHandler).queryCalendar}, {"weather", (*reactiveHandler).queryWeather}, {"embed", (*reactiveHandler).queryEmbed}, @@ -89,6 +95,46 @@ func (h *reactiveHandler) queryFactByKey(ctx context.Context, t *queryTurn) (str return "", false } +// queryDayPlan — "какие планы на сегодня?", "что у меня по плану?", "что +// дальше?" (Vikunja #128). Recites the day: calendar events, pending +// reminders, and any morning checklist still outstanding. +// +// Read-only by construction — the plan is assembled and rendered core-side and +// nothing here schedules or announces. "что дальше?" asks for the rest of the +// day, so that phrasing trims what has already passed. +func (h *reactiveHandler) queryDayPlan(ctx context.Context, t *queryTurn) (string, bool) { + if !router.IsDayPlanQuery(t.dec.Utterance) { + return "", false + } + plan, err := h.api.DayPlan(ctx) + if err != nil { + log.Printf("voice: day plan: %v", err) + return "не получилось собрать план.", true + } + if !isRestOfDayQuery(t.dec.Utterance) { + return plan.Spoken, true + } + // Rebuild the pure plan so the rest-of-day rendering is the same code that + // rendered the whole day — one formatter, one persona. + p := morning.Plan{Date: plan.Date} + for _, it := range plan.Items { + p.Items = append(p.Items, morning.PlanEntry{ + At: it.At, + Text: it.Text, + Kind: morning.PlanKind(it.Kind), + Uncertain: it.Uncertain, + }) + } + return p.After(h.now()).FormatRU(), true +} + +// isRestOfDayQuery — "что дальше?" and its English form, the only plan phrasing +// that means "from now on" rather than "the whole day". +func isRestOfDayQuery(text string) bool { + s := strings.ToLower(text) + return strings.Contains(s, "дальше") || strings.Contains(s, "next") +} + // queryCalendar — "что у меня сегодня?", "планы на завтра?" // h.now(), not time.Now(): the handler's clock is the injected one, so this // source can be tested at a fixed time like the rest. diff --git a/cmd/mavend/dayplan_test.go b/cmd/mavend/dayplan_test.go new file mode 100644 index 0000000..d225cb2 --- /dev/null +++ b/cmd/mavend/dayplan_test.go @@ -0,0 +1,143 @@ +package main + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/kami/maven/internal/ipc" + "github.com/kami/maven/internal/router" +) + +// planAPI answers only DayPlan; every other call is unimplemented, which is +// exactly the assertion that the plan source needs nothing else. +type planAPI struct { + ipc.UnimplementedCoreAPI + plan ipc.DayPlan + err error + calls int +} + +func (a *planAPI) DayPlan(context.Context) (ipc.DayPlan, error) { + a.calls++ + if a.err != nil { + return ipc.DayPlan{}, a.err + } + return a.plan, nil +} + +func planDay() time.Time { return time.Date(2026, 8, 3, 12, 0, 0, 0, time.UTC) } + +func samplePlan() ipc.DayPlan { + day := planDay() + mid := time.Date(2026, 8, 3, 0, 0, 0, 0, time.UTC) + return ipc.DayPlan{ + Date: mid, + Items: []ipc.DayPlanItem{ + {At: day.Add(-2 * time.Hour), Text: "Standup @ 10:00-10:30", Kind: "event"}, + {At: day.Add(2 * time.Hour), Text: "Планёрка @ 14:00-14:30", Kind: "event", Uncertain: true}, + {At: day.Add(6 * time.Hour), Text: "позвонить маме", Kind: "reminder"}, + }, + Spoken: "план на 03.08.2026: 10:00 — Standup @ 10:00-10:30; " + + "похоже, 14:00 — Планёрка @ 14:00-14:30; 18:00 — позвонить маме.", + } +} + +func planHandler(api ipc.CoreAPI) *reactiveHandler { + return &reactiveHandler{api: api, now: planDay} +} + +func TestQueryDayPlanRecitesTheDay(t *testing.T) { + api := &planAPI{plan: samplePlan()} + h := planHandler(api) + reply, ok := h.queryDayPlan(context.Background(), &queryTurn{ + dec: router.Decision{Intent: router.IntentQuery, Utterance: "какие планы на сегодня?"}, + }) + if !ok { + t.Fatal("the plan source must claim a plan question") + } + if reply != api.plan.Spoken { + t.Errorf("reply = %q, want the core's spoken plan %q", reply, api.plan.Spoken) + } +} + +// "что дальше?" is the rest of the day, not the whole day: what has already +// happened is not a plan. +func TestQueryDayPlanTrimsToRestOfDay(t *testing.T) { + h := planHandler(&planAPI{plan: samplePlan()}) + reply, ok := h.queryDayPlan(context.Background(), &queryTurn{ + dec: router.Decision{Intent: router.IntentQuery, Utterance: "что дальше?"}, + }) + if !ok { + t.Fatal("expected the plan source to claim it") + } + if strings.Contains(reply, "Standup") { + t.Errorf("a passed item must not be read back: %q", reply) + } + if !strings.Contains(reply, "Планёрка") || !strings.Contains(reply, "позвонить маме") { + t.Errorf("the rest of the day is missing: %q", reply) + } + // Provenance survives the trim. + if !strings.Contains(reply, "похоже,") { + t.Errorf("a relayed event must stay hedged: %q", reply) + } +} + +// A question that is not about the plan must fall through, or the plan buries +// the calendar listing and the weather behind it. +func TestQueryDayPlanPassesOnEverythingElse(t *testing.T) { + for _, q := range []string{ + "что у меня сегодня?", + "какие планы на завтра?", + "когда планёрка?", + "какая погода?", + "", + } { + api := &planAPI{plan: samplePlan()} + reply, ok := planHandler(api).queryDayPlan(context.Background(), &queryTurn{ + dec: router.Decision{Intent: router.IntentQuery, Utterance: q}, + }) + if ok { + t.Errorf("%q was claimed by the plan source (reply %q)", q, reply) + } + if api.calls != 0 { + t.Errorf("%q hit the core for a plan it does not want", q) + } + } +} + +func TestQueryDayPlanCoreFailure(t *testing.T) { + h := planHandler(&planAPI{err: errors.New("socket closed")}) + reply, ok := h.queryDayPlan(context.Background(), &queryTurn{ + dec: router.Decision{Intent: router.IntentQuery, Utterance: "план на сегодня"}, + }) + if !ok { + t.Fatal("a failed plan read must still answer, not fall through to RAG") + } + if reply != "не получилось собрать план." { + t.Errorf("reply = %q", reply) + } +} + +// The day plan must sit before the calendar listing: both match "…на сегодня", +// and the more specific matcher has to get first refusal (see #373 for what +// happens when the order is wrong). +func TestDayPlanSourcePrecedesCalendar(t *testing.T) { + plan, cal := -1, -1 + for i, s := range querySources { + switch s.name { + case "day-plan": + plan = i + case "calendar": + cal = i + } + } + if plan < 0 || cal < 0 { + t.Fatalf("sources missing: day-plan=%d calendar=%d", plan, cal) + } + if plan > cal { + t.Errorf("day-plan at %d must come before calendar at %d", plan, cal) + } +} diff --git a/cmd/mavend/main.go b/cmd/mavend/main.go index 6e3db2b..e532810 100644 --- a/cmd/mavend/main.go +++ b/cmd/mavend/main.go @@ -270,6 +270,7 @@ func run(args []string) error { CoreAPI: ipc.NewStoreAPI(st), getTrace: tl.trace, getMorningStatus: func(ctx context.Context) []ipc.MorningRoutineStatus { return tl.morningStatus(ctx, time.Now()) }, + getDayPlan: func(ctx context.Context) ipc.DayPlan { return tl.dayPlan(ctx, time.Now()) }, } if voiceW != nil && voiceW.handler != nil { api := coreAPI.(*daemonAPI) @@ -449,6 +450,7 @@ func run(args []string) error { CoreAPI: ipc.NewStoreAPI(st), getTrace: tl.trace, getMorningStatus: func(ctx context.Context) []ipc.MorningRoutineStatus { return tl.morningStatus(ctx, time.Now()) }, + getDayPlan: func(ctx context.Context) ipc.DayPlan { return tl.dayPlan(ctx, time.Now()) }, } if voiceW != nil && voiceW.handler != nil { newAPI.chatFn = voiceW.handler.handleText diff --git a/cmd/mavend/tick.go b/cmd/mavend/tick.go index cc2f991..3932173 100644 --- a/cmd/mavend/tick.go +++ b/cmd/mavend/tick.go @@ -789,6 +789,77 @@ func (t *tickLoop) morningStatus(ctx context.Context, now time.Time) []ipc.Morni return out } +// dayPlan is the read-only "what does today hold" query (Vikunja #128). It is +// the impure half of morning.BuildPlan: it reads the calendar events, the +// pending reminders and the checklist facts, and the pure builder orders them. +// +// It never dispatches. Asking for the plan is a query like any other; the only +// unprompted delivery in maven stays with the morning nudge and the +// dispatcher's policy. +func (t *tickLoop) dayPlan(ctx context.Context, now time.Time) ipc.DayPlan { + y, m, d := now.Date() + dayStart := time.Date(y, m, d, 0, 0, 0, 0, now.Location()) + dayEnd := dayStart.AddDate(0, 0, 1) + + var events []morning.PlanEntry + facts, err := t.store.CalendarEvents(ctx, dayStart, dayEnd) + if err != nil { + log.Printf("tick: day plan: calendar events: %v", err) + } + for _, f := range facts { + events = append(events, morning.PlanEntry{ + At: f.Ts, + Text: f.Value, + Kind: morning.PlanEvent, + // Provenance below a calendar read (an ambient relay, #126) is + // hedged rather than recited as fact. + Uncertain: f.Confidence < 1.0, + }) + } + + var reminders []morning.PlanEntry + rems, err := t.store.ListReminders(ctx, dayPlanMaxReminders) + if err != nil { + log.Printf("tick: day plan: list reminders: %v", err) + } + for _, r := range rems { + if r.Status != "pending" { + continue + } + fire := r.NextFireTs + if fire.IsZero() { + fire = r.FireTs + } + reminders = append(reminders, morning.PlanEntry{ + At: fire, + Text: strings.TrimSpace(r.Payload), + Kind: morning.PlanReminder, + }) + } + + var checklistFacts map[string]store.Fact + if len(t.morningRoutines) > 0 { + checklistFacts = t.gatherMorningFacts(ctx) + } + plan := morning.BuildPlan(t.morningRoutines, checklistFacts, events, reminders, now) + + out := ipc.DayPlan{Date: plan.Date, Spoken: plan.FormatRU()} + out.Items = make([]ipc.DayPlanItem, len(plan.Items)) + for i, it := range plan.Items { + out.Items[i] = ipc.DayPlanItem{ + At: it.At, + Text: it.Text, + Kind: string(it.Kind), + Uncertain: it.Uncertain, + } + } + return out +} + +// dayPlanMaxReminders bounds the reminder scan. The plan covers one day; a +// pending queue longer than this is a bug elsewhere, not a plan to recite. +const dayPlanMaxReminders = 500 + // tune — the feedback auto-tuner's impure step. runs on a slow cadence // (autotuneInterval, see run) so it doesn't write a fact every tick. for each // rule: @@ -868,6 +939,7 @@ type daemonAPI struct { ipc.CoreAPI getTrace func() *loop.TickTrace getMorningStatus func(ctx context.Context) []ipc.MorningRoutineStatus + getDayPlan func(ctx context.Context) ipc.DayPlan chatFn func(ctx context.Context, text string) string } @@ -893,6 +965,13 @@ func (d *daemonAPI) MorningStatus(ctx context.Context) ([]ipc.MorningRoutineStat return d.getMorningStatus(ctx), nil } +func (d *daemonAPI) DayPlan(ctx context.Context) (ipc.DayPlan, error) { + if d.getDayPlan == nil { + return ipc.DayPlan{}, errors.New("mavend: day plan not available") + } + return d.getDayPlan(ctx), nil +} + func toIPCTickTrace(t loop.TickTrace) ipc.TickTrace { rules := make([]ipc.RuleTrace, len(t.RuleTraces)) for i, r := range t.RuleTraces { diff --git a/cmd/mavweb/main.go b/cmd/mavweb/main.go index 3cf712b..9086f4b 100644 --- a/cmd/mavweb/main.go +++ b/cmd/mavweb/main.go @@ -959,12 +959,32 @@ func handleMorning(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { http.Error(w, "core read failed", http.StatusBadGateway) return } + view := morningView{Routines: status} + // The day plan (#128) shows on this page because it is the same question at + // a different scale. A plan read that fails must not take the checklist + // down with it — the page degrades to what it had before. + plan, err := core.DayPlan(ctx) + if err != nil { + log.Printf("morning: day plan: %v", err) + view.PlanErr = err.Error() + } else { + view.Plan = &plan + } w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := morningTmpl.Execute(w, status); err != nil { + if err := morningTmpl.Execute(w, view); err != nil { log.Printf("morning render: %v", err) } } +// morningView — what /morning renders: today's plan on top, the checklist +// state under it. PlanErr is set instead of Plan when the core could not build +// a plan, so the page says so rather than showing an empty day. +type morningView struct { + Plan *ipc.DayPlan + PlanErr string + Routines []ipc.MorningRoutineStatus +} + func handleVoice(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := voiceTmpl.Execute(w, nil); err != nil { diff --git a/cmd/mavweb/morning.html b/cmd/mavweb/morning.html index abf5ae1..3db912e 100644 --- a/cmd/mavweb/morning.html +++ b/cmd/mavweb/morning.html @@ -1,9 +1,27 @@ {{template "shellTop" "morning"}} +

Today

+{{with .Plan}} +
{{.Date.Format "02.01.2006"}}
+{{if not .Items}} +
nothing planned
+{{else}} +
+ +{{range .Items}} + + + +{{end}} +
atkindwhat
{{.At.Format "15:04"}}{{.Kind}}{{if .Uncertain}}похоже, {{end}}{{.Text}}
+{{end}} +{{end}} +{{if .PlanErr}}
plan unavailable: {{.PlanErr}}
{{end}} +

Morning Routines

-{{if not .}} +{{if not .Routines}}
no morning routines configured
{{else}} -{{range .}} +{{range .Routines}}
{{.Name}} {{if .Active}}active now{{else}}outside window{{end}} diff --git a/internal/ipc/api.go b/internal/ipc/api.go index 6f879b8..fe779e4 100644 --- a/internal/ipc/api.go +++ b/internal/ipc/api.go @@ -306,6 +306,14 @@ type CoreAPI interface { // TickTrace. MorningStatus(ctx context.Context) ([]MorningRoutineStatus, error) + // DayPlan returns today's ordered plan — calendar events, pending + // reminders and any morning checklist still outstanding (see + // internal/morning.BuildPlan) — plus the spoken RU rendering of it. + // Read-only: asking for the plan never dispatches or schedules anything. + // The store adapter returns an error (the plan needs the daemon's routine + // config) — same shape as TickTrace and MorningStatus. + DayPlan(ctx context.Context) (DayPlan, error) + // Chat routes a text utterance through the reactive handler's core path // (router → dialogue → action → replier) and returns the reply text. // No audio or stt/tts — for text channels (mavweb, telegram). @@ -359,6 +367,26 @@ type MorningRoutineStatus struct { Items []MorningRoutineItem `json:"items"` } +// DayPlanItem — one line of the day plan. Kind is "event", "reminder" or +// "checklist"; Uncertain marks an item whose provenance is below a full +// calendar read (a meeting relayed off a phone notification), so a UI can hedge +// the same way the spoken form does. +type DayPlanItem struct { + At time.Time `json:"at"` + Text string `json:"text"` + Kind string `json:"kind"` + Uncertain bool `json:"uncertain,omitempty"` +} + +// DayPlan — the plan for one calendar day. Spoken is the RU sentence maven +// says when asked, rendered core-side so the voice reply and the web view can +// never drift apart. +type DayPlan struct { + Date time.Time `json:"date"` + Items []DayPlanItem `json:"items"` + Spoken string `json:"spoken"` +} + // storeEncryptionKeyReq — passkey credential public key for wrapping the store // encryption key at enrollment time. Called by mavweb after RegisterFinish. type storeEncryptionKeyReq struct { diff --git a/internal/ipc/client.go b/internal/ipc/client.go index 208c250..5717ee1 100644 --- a/internal/ipc/client.go +++ b/internal/ipc/client.go @@ -71,6 +71,7 @@ var readOnlyMethods = map[Method]bool{ MethodListProposedRoutines: true, MethodTickTrace: true, MethodMorningStatus: true, + MethodDayPlan: true, } // Dial connects to a core socket at path and returns a Client. The module @@ -458,6 +459,14 @@ func (c *Client) MorningStatus(ctx context.Context) ([]MorningRoutineStatus, err return s, nil } +func (c *Client) DayPlan(ctx context.Context) (DayPlan, error) { + var p DayPlan + if err := c.call(ctx, MethodDayPlan, nil, &p); err != nil { + return DayPlan{}, err + } + return p, nil +} + func (c *Client) RevertFact(ctx context.Context, key string) (int64, error) { var result struct { NewID int64 `json:"new_id"` diff --git a/internal/ipc/server.go b/internal/ipc/server.go index 91e4bc1..21fbb1f 100644 --- a/internal/ipc/server.go +++ b/internal/ipc/server.go @@ -211,6 +211,10 @@ func (a *storeAPI) MorningStatus(ctx context.Context) ([]MorningRoutineStatus, e return nil, errors.New("store: morning status not available via direct store API") } +func (a *storeAPI) DayPlan(ctx context.Context) (DayPlan, error) { + return DayPlan{}, errors.New("store: day plan not available via direct store API") +} + func (a *storeAPI) ListTools(ctx context.Context, status string) ([]Tool, error) { ts, err := a.s.ListTools(ctx, status) if err != nil { @@ -729,6 +733,9 @@ var methodTable = map[Method]handlerFunc{ // MorningStatus intentionally has no nil→[]T{} normalization here — the // pre-table arm marshaled api.MorningStatus's result as-is (a nil slice // serializes as JSON null), and this preserves that exact wire shape. + MethodDayPlan: withoutParams(func(ctx context.Context, api CoreAPI) (DayPlan, error) { + return api.DayPlan(ctx) + }), MethodMorningStatus: withoutParams(func(ctx context.Context, api CoreAPI) ([]MorningRoutineStatus, error) { return api.MorningStatus(ctx) }), diff --git a/internal/ipc/unimplemented.go b/internal/ipc/unimplemented.go index 5407f96..daef767 100644 --- a/internal/ipc/unimplemented.go +++ b/internal/ipc/unimplemented.go @@ -113,6 +113,9 @@ func (UnimplementedCoreAPI) TickTrace(ctx context.Context) (TickTrace, error) { func (UnimplementedCoreAPI) MorningStatus(ctx context.Context) ([]MorningRoutineStatus, error) { return nil, ErrNotImplemented } +func (UnimplementedCoreAPI) DayPlan(ctx context.Context) (DayPlan, error) { + return DayPlan{}, ErrNotImplemented +} func (UnimplementedCoreAPI) Chat(ctx context.Context, text string) (string, error) { return "", ErrNotImplemented } diff --git a/internal/ipc/wire.go b/internal/ipc/wire.go index c67cec1..e1c4dad 100644 --- a/internal/ipc/wire.go +++ b/internal/ipc/wire.go @@ -45,6 +45,7 @@ const ( MethodRevertFact Method = "revert_fact" MethodTickTrace Method = "tick_trace" MethodMorningStatus Method = "morning_status" + MethodDayPlan Method = "day_plan" MethodChat Method = "chat" ) diff --git a/internal/morning/plan.go b/internal/morning/plan.go new file mode 100644 index 0000000..530a33a --- /dev/null +++ b/internal/morning/plan.go @@ -0,0 +1,165 @@ +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. +type Plan struct { + Date time.Time + Items []PlanEntry +} + +// 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, is not in its window, or is already +// complete contributes nothing: the plan says what is left, not what was done. +func checklistEntries(routines []Routine, facts map[string]store.Fact, now time.Time) []PlanEntry { + var out []PlanEntry + for _, r := range routines { + st := Evaluate(r, facts, now) + if !st.Active || len(st.Missing) == 0 { + continue + } + labels := make([]string, 0, len(st.Missing)) + for _, it := range st.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} + 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 { + 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, "; ")) +} diff --git a/internal/morning/plan_test.go b/internal/morning/plan_test.go new file mode 100644 index 0000000..35ff4d1 --- /dev/null +++ b/internal/morning/plan_test.go @@ -0,0 +1,169 @@ +package morning + +import ( + "strings" + "testing" + "time" + + "github.com/kami/maven/internal/store" +) + +// planAt is at() for the plan tests' day (2026-08-03, a Monday); the existing +// at() in morning_test.go is pinned to a different date. +func planAt(now time.Time, hh, mm int) time.Time { + y, m, d := now.Date() + return time.Date(y, m, d, hh, mm, 0, 0, now.Location()) +} + +func planFixture(t *testing.T) (Plan, time.Time) { + t.Helper() + now := time.Date(2026, 8, 3, 9, 0, 0, 0, time.UTC) + routines := []Routine{{ + Name: "утро", + WindowStart: "07:00", + WindowEnd: "11:00", + NudgeAt: "10:30", + Items: []Item{ + {Key: "water", FactKey: "drank_water", Label: "выпить воды"}, + {Key: "pills", FactKey: "took_pills", Label: "витамины"}, + }, + }} + facts := map[string]store.Fact{ + "drank_water": {Ts: planAt(now, 8, 0)}, + } + events := []PlanEntry{ + {At: planAt(now, 14, 0), Text: "Планёрка @ 14:00-14:30", Kind: PlanEvent, Uncertain: true}, + {At: planAt(now, 10, 0), Text: "Standup @ 10:00-10:30", Kind: PlanEvent}, + } + reminders := []PlanEntry{ + {At: planAt(now, 18, 30), Text: "позвонить маме", Kind: PlanReminder}, + } + return BuildPlan(routines, facts, events, reminders, now), now +} + +func TestBuildPlanOrdersTheDay(t *testing.T) { + p, now := planFixture(t) + + if !p.Date.Equal(planAt(now, 0, 0)) { + t.Errorf("Date = %v, want midnight of now's day", p.Date) + } + want := []struct { + hhmm string + kind PlanKind + }{ + {"10:00", PlanEvent}, + {"10:30", PlanChecklist}, + {"14:00", PlanEvent}, + {"18:30", PlanReminder}, + } + if len(p.Items) != len(want) { + t.Fatalf("got %d items, want %d: %+v", len(p.Items), len(want), p.Items) + } + for i, w := range want { + if got := p.Items[i].At.Format("15:04"); got != w.hhmm { + t.Errorf("item %d at %s, want %s", i, got, w.hhmm) + } + if p.Items[i].Kind != w.kind { + t.Errorf("item %d kind %q, want %q", i, p.Items[i].Kind, w.kind) + } + } +} + +// The checklist line says what is LEFT. An item already evidenced today must +// not be read back as outstanding. +func TestBuildPlanChecklistListsOnlyMissing(t *testing.T) { + p, _ := planFixture(t) + var line string + for _, it := range p.Items { + if it.Kind == PlanChecklist { + line = it.Text + } + } + if line == "" { + t.Fatal("no checklist line in the plan") + } + if !strings.Contains(line, "витамины") { + t.Errorf("missing item not listed: %q", line) + } + if strings.Contains(line, "выпить воды") { + t.Errorf("a completed item must not be read back as outstanding: %q", line) + } + if !strings.HasPrefix(line, "утро — осталось:") { + t.Errorf("line = %q", line) + } +} + +func TestBuildPlanSkipsCompleteAndInactiveRoutines(t *testing.T) { + now := time.Date(2026, 8, 3, 9, 0, 0, 0, time.UTC) + routines := []Routine{ + { + Name: "утро", WindowStart: "07:00", WindowEnd: "11:00", + Items: []Item{{Key: "water", FactKey: "drank_water", Label: "выпить воды"}}, + }, + { + // Not in its window at 09:00. + Name: "вечер", WindowStart: "20:00", WindowEnd: "23:00", + Items: []Item{{Key: "walk", FactKey: "walked", Label: "прогулка"}}, + }, + } + facts := map[string]store.Fact{"drank_water": {Ts: planAt(now, 8, 0)}} + p := BuildPlan(routines, facts, nil, nil, now) + if len(p.Items) != 0 { + t.Fatalf("a complete routine and an out-of-window one must contribute nothing: %+v", p.Items) + } + if got, want := p.FormatRU(), "на 03.08.2026 ничего не запланировано."; got != want { + t.Errorf("got %q\nwant %q", got, want) + } +} + +// A plan for today that includes tomorrow's meeting is worse than terse. +func TestBuildPlanDropsOtherDays(t *testing.T) { + now := time.Date(2026, 8, 3, 9, 0, 0, 0, time.UTC) + events := []PlanEntry{ + {At: planAt(now, 10, 0), Text: "today", Kind: PlanEvent}, + {At: planAt(now, 10, 0).AddDate(0, 0, 1), Text: "tomorrow", Kind: PlanEvent}, + {At: planAt(now, 10, 0).AddDate(0, 0, -1), Text: "yesterday", Kind: PlanEvent}, + {At: planAt(now, 12, 0), Text: " ", Kind: PlanEvent}, + } + p := BuildPlan(nil, nil, events, nil, now) + if len(p.Items) != 1 || p.Items[0].Text != "today" { + t.Fatalf("got %+v", p.Items) + } +} + +func TestPlanFormatRU(t *testing.T) { + p, _ := planFixture(t) + got := p.FormatRU() + want := "план на 03.08.2026: 10:00 — Standup @ 10:00-10:30; " + + "10:30 — утро — осталось: витамины; " + + "похоже, 14:00 — Планёрка @ 14:00-14:30; " + + "18:30 — позвонить маме." + if got != want { + t.Errorf("got %q\nwant %q", got, want) + } + // Persona: she recites, she does not exhort, and she never speaks of + // herself in the masculine or addresses him formally. + for _, bad := range []string{"рад ", "понял", "вы ", "ваш", "милый", "дорогой", "давай же", "не забудь"} { + if strings.Contains(strings.ToLower(got), bad) { + t.Errorf("plan text contains %q: %q", bad, got) + } + } +} + +func TestPlanAfter(t *testing.T) { + p, now := planFixture(t) + rest := p.After(planAt(now, 11, 0)) + if len(rest.Items) != 2 { + t.Fatalf("got %d items, want the 14:00 and 18:30 ones: %+v", len(rest.Items), rest.Items) + } + if !rest.Date.Equal(p.Date) { + t.Error("After must keep the date, so an empty rest-of-day still knows which day") + } + empty := p.After(planAt(now, 23, 0)) + if len(empty.Items) != 0 { + t.Errorf("got %+v", empty.Items) + } + if !strings.Contains(empty.FormatRU(), "ничего не запланировано") { + t.Errorf("empty plan reads %q", empty.FormatRU()) + } +} diff --git a/internal/router/calendar.go b/internal/router/calendar.go index 6188672..3f86976 100644 --- a/internal/router/calendar.go +++ b/internal/router/calendar.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" "time" + "unicode" ) // CalendarEventFormatter formats calendar events into a Russian reply string. @@ -18,6 +19,71 @@ type CalendarEntry struct { Uncertain bool } +// dayPlanWords — the tokens that ask for the day as a whole rather than for a +// calendar listing. Whole words, not substrings: "планёрка" is a MEETING, and a +// notification about one must not be mistaken for a request for the plan. +var dayPlanWords = []string{ + "план", "плана", "плану", "плане", "планом", + "планы", "планов", "планам", "планах", + "расписание", "расписании", "распорядок", "распорядке", + "plan", "plans", "schedule", "agenda", +} + +// otherDayWords — a day that is not today. The plan is built for the clock's +// own day only, so an utterance naming another one belongs to the calendar +// listing instead. Claiming it here would answer the wrong day, which is worse +// than answering more tersely. +var otherDayWords = []string{ + "завтра", "послезавтра", "вчера", "позавчера", + "tomorrow", "yesterday", +} + +// IsDayPlanQuery reports whether an utterance asks for today's plan (Vikunja +// #128) — "какие планы на сегодня?", "что у меня по плану?", "что дальше?". +// +// Deliberately narrow. The calendar listing already answers "что у меня +// сегодня?" and a plan that hijacks every date-bearing question would bury the +// events under checklist lines. Only a plan-shaped ask, and only about today. +func IsDayPlanQuery(text string) bool { + toks := planTokens(text) + for _, t := range toks { + for _, w := range otherDayWords { + if t == w { + return false + } + } + } + for _, t := range toks { + for _, w := range dayPlanWords { + if t == w { + return true + } + } + } + // "что дальше?" / "what's next?" — the rest of the day, with no plan word + // in it. Both tokens rather than adjacency, because "what's" splits into + // "what" and "s" and because "и что потом дальше" is the same question. + return (hasTok(toks, "что") && hasTok(toks, "дальше")) || + (hasTok(toks, "what") && hasTok(toks, "next")) +} + +func hasTok(toks []string, w string) bool { + for _, t := range toks { + if t == w { + return true + } + } + return false +} + +// planTokens lowercases and splits on everything that is not a letter or a +// digit, so "планы?" and "что-дальше" tokenize like the plain words do. +func planTokens(text string) []string { + return strings.FieldsFunc(strings.ToLower(text), func(r rune) bool { + return !unicode.IsLetter(r) && !unicode.IsDigit(r) + }) +} + // Format returns a Russian reply for the given calendar events on the given // date. Every event is treated as certain — use FormatEntries when provenance // differs between them. diff --git a/internal/router/calendar_test.go b/internal/router/calendar_test.go index b955560..4772733 100644 --- a/internal/router/calendar_test.go +++ b/internal/router/calendar_test.go @@ -45,3 +45,39 @@ func TestCalendarEventFormatterHedgesUncertainEntries(t *testing.T) { t.Errorf("empty: got %q", got) } } + +func TestIsDayPlanQuery(t *testing.T) { + yes := []string{ + "какие планы на сегодня?", + "что у меня по плану", + "расскажи план", + "мой распорядок на сегодня", + "расписание?", + "что дальше?", + "what's next", + "what is my plan today", + } + for _, s := range yes { + if !IsDayPlanQuery(s) { + t.Errorf("IsDayPlanQuery(%q) = false, want true", s) + } + } + + no := []string{ + // The calendar listing owns these. + "что у меня сегодня?", + "какие планы на завтра?", + "план на послезавтра", + "что было вчера", + // "планёрка" is a meeting, not a request for the plan. + "когда планёрка?", + "запиши планёрку на 14:00", + "какая погода?", + "", + } + for _, s := range no { + if IsDayPlanQuery(s) { + t.Errorf("IsDayPlanQuery(%q) = true, want false", s) + } + } +}