diff --git a/cmd/mavend/actions_query.go b/cmd/mavend/actions_query.go index c41984b..8edba2b 100644 --- a/cmd/mavend/actions_query.go +++ b/cmd/mavend/actions_query.go @@ -235,7 +235,13 @@ func (h *reactiveHandler) queryFactByKey(ctx context.Context, t *queryTurn) (str // // 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. +// day, so that phrasing trims what has already passed and reads only the next +// morning.NextSpoken entries. Trimming alone was not enough: asked early it cuts +// nothing, and she read 43 entries aloud in one sentence (V-618). +// +// "что у меня сегодня?" is a different question and is not narrowed here — it +// carries no plan word, so IsDayPlanQuery declines it and the calendar source +// answers the whole day. // // What surface this belongs on is still open, tracked as Vikunja #431 ("Board // surface: Maven holds the work board, runs the intake form, never argues"). @@ -263,7 +269,7 @@ func (h *reactiveHandler) queryDayPlan(ctx context.Context, t *queryTurn) (strin Uncertain: it.Uncertain, }) } - return p.After(h.now()).FormatRU(), true + return p.Next(h.now(), morning.NextSpoken).FormatRU(), true } // habitFactWindow — how many recent SELF facts the behaviour profile is counted diff --git a/cmd/mavend/dayplan_test.go b/cmd/mavend/dayplan_test.go index 52a0aa9..6662b21 100644 --- a/cmd/mavend/dayplan_test.go +++ b/cmd/mavend/dayplan_test.go @@ -4,12 +4,14 @@ import ( "context" "database/sql" "errors" + "fmt" "strings" "testing" "time" "github.com/kami/maven/internal/calendar" "github.com/kami/maven/internal/ipc" + "github.com/kami/maven/internal/morning" "github.com/kami/maven/internal/phraser" "github.com/kami/maven/internal/router" "github.com/kami/maven/internal/store" @@ -111,6 +113,57 @@ func TestQueryDayPlanRestOfDayWhenNothingIsLeft(t *testing.T) { } } +// The defect V-618 fixes, at the handler: asked at 04:45 the trim removes +// nothing, because the whole day is still ahead. She read 43 entries aloud as +// one sentence. The zone is three hours off UTC so the test also fails under +// TZ=UTC if the rendering ever slips zones. +func TestQueryDayPlanCapsWhatItReadsAloud(t *testing.T) { + zone := time.FixedZone("MSK", 3*60*60) + mid := time.Date(2026, 8, 3, 0, 0, 0, 0, zone) + plan := ipc.DayPlan{Date: mid, Spoken: "план на 03.08.2026: …"} + for i := 0; i < 43; i++ { + plan.Items = append(plan.Items, ipc.DayPlanItem{ + At: mid.Add(time.Duration(345+i*20) * time.Minute), // 05:45 onward + Text: fmt.Sprintf("пункт %d", i), + Kind: "event", + }) + } + h := &reactiveHandler{api: &planAPI{plan: plan}, now: func() time.Time { + return time.Date(2026, 8, 3, 4, 45, 0, 0, zone) + }} + 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 n := strings.Count(reply, "пункт "); n != morning.NextSpoken { + t.Errorf("read %d entries aloud, want %d: %q", n, morning.NextSpoken, reply) + } + if !strings.HasPrefix(reply, "дальше: 05:45 — пункт 0;") { + t.Errorf("the next thing is not first: %q", reply) + } + // The rest is counted, not silently dropped. + if !strings.Contains(reply, "и ещё 40 дел до конца дня.") { + t.Errorf("the sentence hides that the day goes on: %q", reply) + } +} + +// "что у меня сегодня?" is the whole day and is not narrowed. It carries no +// plan word, so the plan source declines it and the calendar listing answers — +// asserted here beside the cap so the two questions cannot drift together. +func TestWholeDayQuestionIsNotTheRestOfTheDay(t *testing.T) { + if router.IsDayPlanQuery("что у меня сегодня?") { + t.Error("the plan source claims the whole-day question") + } + if !router.IsDayPlanQuery("что дальше?") { + t.Error("the plan source stopped claiming the rest-of-day question") + } + if router.IsRestOfDayQuery("какие планы на сегодня?") { + t.Error("the whole-day plan question got narrowed to the rest of the day") + } +} + // 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) { diff --git a/internal/morning/plan.go b/internal/morning/plan.go index ee863a2..80c853d 100644 --- a/internal/morning/plan.go +++ b/internal/morning/plan.go @@ -52,10 +52,13 @@ type PlanEntry struct { // 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 @@ -142,13 +145,24 @@ func checklistEntries(routines []Routine, facts map[string]store.Fact, now time. 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.Before(now) { + if !it.At.After(now) { continue } out.Items = append(out.Items, it) @@ -156,6 +170,18 @@ func (p Plan) After(now time.Time) Plan { 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. @@ -177,8 +203,22 @@ func (p Plan) FormatRU() string { } 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": strings.Join(parts, "; "), + "items": items, }) } diff --git a/internal/morning/plan_test.go b/internal/morning/plan_test.go index 3448412..99671c9 100644 --- a/internal/morning/plan_test.go +++ b/internal/morning/plan_test.go @@ -1,6 +1,7 @@ package morning import ( + "fmt" "strings" "testing" "time" @@ -171,6 +172,84 @@ func TestPlanAfter(t *testing.T) { } } +// nextFixture — a day with more entries than the cap, built in a zone three +// hours off UTC so the test fails under TZ=UTC as well as under the machine's +// own zone if the plan ever renders in the wrong one. +func nextFixture(t *testing.T) (Plan, time.Time) { + t.Helper() + zone := time.FixedZone("MSK", 3*60*60) + now := time.Date(2026, 8, 3, 4, 45, 0, 0, zone) + var events []PlanEntry + for _, hhmm := range [][2]int{{5, 45}, {10, 0}, {14, 0}, {18, 30}, {21, 12}} { + events = append(events, PlanEntry{ + At: planAt(now, hhmm[0], hhmm[1]), + Text: fmt.Sprintf("событие %02d:%02d", hhmm[0], hhmm[1]), + Kind: PlanEvent, + }) + } + return BuildPlan(nil, nil, events, nil, now), now +} + +// "что дальше?" asked at 04:45 on a day with everything still ahead. The trim +// removes nothing there, so before V-618 she read the whole day out loud. +func TestPlanNextCapsWhatIsSpoken(t *testing.T) { + p, now := nextFixture(t) + got := p.Next(now, NextSpoken).FormatRU() + want := "дальше: 05:45 — событие 05:45; 10:00 — событие 10:00; " + + "14:00 — событие 14:00. и ещё 2 дела до конца дня." + if got != want { + t.Errorf("got %q\nwant %q", got, want) + } + // No date: he asked what is next, not what day it is. + if strings.Contains(got, "03.08.2026") { + t.Errorf("rest-of-day answer stamps a date: %q", got) + } +} + +// Nothing hidden means nothing claimed hidden. +func TestPlanNextWithinTheCapSaysNoMore(t *testing.T) { + p, now := nextFixture(t) + got := p.Next(planAt(now, 15, 0), NextSpoken).FormatRU() + want := "дальше: 18:30 — событие 18:30; 21:12 — событие 21:12" + if got != want { + t.Errorf("got %q\nwant %q", got, want) + } +} + +// The whole-day question is not narrowed: same plan, no trim, no cap. +func TestPlanWholeDayIsNotNarrowed(t *testing.T) { + p, _ := nextFixture(t) + got := p.FormatRU() + if n := strings.Count(got, "событие"); n != 5 { + t.Errorf("whole day read %d of 5 entries: %q", n, got) + } + if !strings.HasPrefix(got, "план на 03.08.2026: ") { + t.Errorf("whole day lost its date: %q", got) + } +} + +// The empty case says the day is over rather than returning an empty sentence, +// and it does not say the day was empty. +func TestPlanNextEmptySaysSo(t *testing.T) { + p, now := nextFixture(t) + got := p.Next(planAt(now, 23, 30), NextSpoken).FormatRU() + if got != "на сегодня больше ничего не запланировано." { + t.Errorf("got %q", got) + } +} + +// An entry at exactly the asking minute is what is happening, not what is next. +func TestPlanNextIsStrictlyAfterNow(t *testing.T) { + p, now := nextFixture(t) + rest := p.Next(planAt(now, 5, 45), NextSpoken) + if len(rest.Items) != 3 || rest.Items[0].At.Hour() != 10 { + t.Errorf("got %+v", rest.Items) + } + if rest.More != 1 { + t.Errorf("More = %d, want 1", rest.More) + } +} + // The plan says what today still has not got done, and a closed window does not // make a skipped routine untrue. Evaluate reports Active only inside the // window, so keying the checklist line off it meant the one thing the plan can diff --git a/internal/say/summary.go b/internal/say/summary.go index bc2b575..7a72899 100644 --- a/internal/say/summary.go +++ b/internal/say/summary.go @@ -32,6 +32,12 @@ const ( PlanDay = "plan_day" PlanUncertain = "plan_uncertain" + // "что дальше?" — the next few entries, not the day. PlanNextMore is the + // same sentence when the cap hid something, so the count it states is the + // only signal that the day is not over after the last line read. + PlanNext = "plan_next" + PlanNextMore = "plan_next_more" + TasksNone = "tasks_none" TasksFirst = "tasks_first" TasksCandidates = "tasks_candidates" @@ -70,6 +76,7 @@ const ( var summaryKeys = []string{ PlanRestEmpty, PlanDayEmpty, PlanDay, PlanUncertain, + PlanNext, PlanNextMore, TasksNone, TasksFirst, TasksCandidates, StallOverdue, StallSitting, StallUnconfirmed, ReasonOverdue, ReasonOverdueDays, ReasonToday, ReasonTomorrow, @@ -89,6 +96,8 @@ var summaryFloor = map[string]string{ PlanDayEmpty: "на {date} ничего не запланировано.", PlanDay: "план на {date}: {items}", PlanUncertain: "похоже, {line}", + PlanNext: "дальше: {items}", + PlanNextMore: "дальше: {items}. и ещё {n} {word} до конца дня.", TasksNone: "задач нет.", TasksFirst: "сначала: {items}", @@ -139,6 +148,8 @@ func LoadSummaries(src rand.Source) (*Summaries, error) { for _, req := range []struct{ key, ph string }{ {PlanDayEmpty, "{date}"}, {PlanDay, "{date}"}, {PlanDay, "{items}"}, {PlanUncertain, "{line}"}, + {PlanNext, "{items}"}, + {PlanNextMore, "{items}"}, {PlanNextMore, "{n}"}, {PlanNextMore, "{word}"}, {TasksFirst, "{items}"}, {TasksCandidates, "{items}"}, {StallOverdue, "{n}"}, {StallOverdue, "{word}"}, {StallSitting, "{n}"}, {StallSitting, "{word}"}, diff --git a/internal/say/summary_ru_v1.json b/internal/say/summary_ru_v1.json index 74048fc..6a0af41 100644 --- a/internal/say/summary_ru_v1.json +++ b/internal/say/summary_ru_v1.json @@ -30,6 +30,14 @@ "fixed": true, "variants": ["похоже, {line}"] }, + "plan_next": { + "fixed": true, + "variants": ["дальше: {items}"] + }, + "plan_next_more": { + "fixed": true, + "variants": ["дальше: {items}. и ещё {n} {word} до конца дня."] + }, "tasks_none": { "fixed": true,