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}}
+
+{{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)
+ }
+ }
+}