Add the day plan she can recite when asked (#128) #58

Closed
claude wants to merge 1 commits from overnight/day-plan into overnight/ambient-calendar
Contributor

What

The day plan: one ordered view of today, built only when asked.

  • internal/morning/plan.goBuildPlan (pure) merges calendar events, pending reminders and one line per morning routine with items still outstanding, drops anything outside the clock's own day, and orders by time/kind/text. Plan.After is the rest-of-day view; Plan.FormatRU is the spoken rendering.
  • internal/ipc — new day_plan method with DayPlan/DayPlanItem DTOs. The store adapter refuses it (the plan needs the daemon's routine config), like TickTrace and MorningStatus.
  • cmd/mavend/tick.gotickLoop.dayPlan is the impure half: reads CalendarEvents, pending reminders and the checklist facts, then calls the pure builder. Wired as daemonAPI.getDayPlan at both construction sites.
  • cmd/mavend/actions_query.go — a day-plan query source, gated by router.IsDayPlanQuery.
  • cmd/mavweb — the plan renders on the existing /morning page, above the checklist.

Why

The plan belongs with the morning routine engine, not beside it. The routine knows what is 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.

Provenance from #126 travels all the way through: an event relayed off a phone notification is stored below full confidence, and the plan says "похоже, 14:00 — …" rather than reciting a guess as fact.

It is not a nag. Nothing in this change fires, schedules or announces — the plan is assembled only in response to a question, over IPC or on a page load. Unprompted delivery stays with the existing morning nudge and the dispatcher's policy.

Two ordering decisions worth reviewing:

  • The day-plan source sits before calendar in querySources. Both match "…на сегодня", and the plan's matcher is the more specific one (it requires a plan word), so the calendar listing would otherwise swallow it. Order in that table is load-bearing — see #373.
  • IsDayPlanQuery matches whole words, so "планёрка" (a meeting) is not read as a request for the plan, and it refuses any utterance naming another day, because the plan is built for the clock's own day only. Answering the wrong day is worse than answering more tersely.

How verified

make build and make test both exit 0.

New tests: plan ordering, the checklist listing only what is left, other-day and blank-entry rejection, the exact RU rendering scanned against the persona rules (feminine, informal, no pet names, no exhortation), rest-of-day trimming with provenance preserved, the day-plan-before-calendar ordering, a core failure answering rather than falling through to RAG, and the matcher's refusals.

Vikunja #128

## What The day plan: one ordered view of today, built only when asked. - `internal/morning/plan.go` — `BuildPlan` (pure) merges calendar events, pending reminders and one line per morning routine with items still outstanding, drops anything outside the clock's own day, and orders by time/kind/text. `Plan.After` is the rest-of-day view; `Plan.FormatRU` is the spoken rendering. - `internal/ipc` — new `day_plan` method with `DayPlan`/`DayPlanItem` DTOs. The store adapter refuses it (the plan needs the daemon's routine config), like `TickTrace` and `MorningStatus`. - `cmd/mavend/tick.go` — `tickLoop.dayPlan` is the impure half: reads `CalendarEvents`, pending reminders and the checklist facts, then calls the pure builder. Wired as `daemonAPI.getDayPlan` at both construction sites. - `cmd/mavend/actions_query.go` — a `day-plan` query source, gated by `router.IsDayPlanQuery`. - `cmd/mavweb` — the plan renders on the existing `/morning` page, above the checklist. ## Why The plan belongs with the morning routine engine, not beside it. The routine knows what is 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. Provenance from #126 travels all the way through: an event relayed off a phone notification is stored below full confidence, and the plan says "похоже, 14:00 — …" rather than reciting a guess as fact. It is not a nag. Nothing in this change fires, schedules or announces — the plan is assembled only in response to a question, over IPC or on a page load. Unprompted delivery stays with the existing morning nudge and the dispatcher's policy. Two ordering decisions worth reviewing: - The `day-plan` source sits **before** `calendar` in `querySources`. Both match "…на сегодня", and the plan's matcher is the more specific one (it requires a plan word), so the calendar listing would otherwise swallow it. Order in that table is load-bearing — see #373. - `IsDayPlanQuery` matches **whole words**, so "планёрка" (a meeting) is not read as a request for the plan, and it refuses any utterance naming another day, because the plan is built for the clock's own day only. Answering the wrong day is worse than answering more tersely. ## How verified `make build` and `make test` both exit 0. New tests: plan ordering, the checklist listing only what is *left*, other-day and blank-entry rejection, the exact RU rendering scanned against the persona rules (feminine, informal, no pet names, no exhortation), rest-of-day trimming with provenance preserved, the day-plan-before-calendar ordering, a core failure answering rather than falling through to RAG, and the matcher's refusals. Vikunja #128
claude added 1 commit 2026-08-01 00:15:42 +02:00
The plan answers "какие планы на сегодня?" by putting one day in order:
calendar events (with #126's ambient provenance carried through and hedged),
pending reminders, and one line per morning routine that still has items
outstanding. "что дальше?" trims what has already passed.

It lives in internal/morning, not in a parallel system, because it is the same
question the checklist asks at a different scale — the routine knows what is
missing from a window, the plan knows what the whole day holds, and both read
the same facts and the same idea of "today". BuildPlan is pure; tickLoop.dayPlan
is the impure half that reads the store.

It is not a nag. Nothing here fires, schedules or announces: the plan is built
only when asked, over IPC (day_plan) or on the existing /morning page.
Unprompted delivery stays with the morning nudge and the dispatcher's policy.

The query source sits before "calendar" in querySources because both match
"…на сегодня" and the plan's matcher is the more specific one; IsDayPlanQuery
matches whole words so "планёрка" (a meeting) is not read as a request for the
plan, and refuses any utterance naming another day, since the plan is built for
the clock's own day only.

Verified: make build and make test both exit 0; new tests cover plan ordering,
the checklist-only-what-is-left rule, other-day rejection, the RU rendering
against the persona checks, rest-of-day trimming, the source ordering, and the
matcher's refusals.
kami reviewed 2026-08-01 10:29:18 +02:00
@@ -89,6 +95,46 @@ func (h *reactiveHandler) queryFactByKey(ctx context.Context, t *queryTurn) (str
return "", false
}
// queryDayPlan — "какие планы на сегодня?", "что у меня по плану?", "что
Owner

oh, you know what?
there's a thing I've tried yesterday, we can actually utilize it and it will be more comfortable and natural for me in terms of "day plans".

oh, you know what? there's a thing I've tried yesterday, we can actually utilize it and it will be more comfortable and natural for me in terms of "day plans".
claude reviewed 2026-08-01 11:30:13 +02:00
claude left a comment
Author
Contributor

The split is right. internal/morning/plan.go is pure and the daemon owns every read, so the plan is testable at a fixed clock. Putting Spoken in the IPC payload means the voice reply and /morning cannot drift. IsDayPlanQuery tokenizes instead of substring-matching, and the comment about "планёрка" shows why that matters. The read-only claim holds: nothing in tick.go:dayPlan writes or dispatches.

7f42cc7 touches cmd/mavend/actions_query.go here, but only the weather branch. Nothing in this PR is fixed on the tip.

1. CalDAV events are recited at the wrong hour, and evening ones disappear.

parseDT reads a local DTSTART as UTC and says so in its own comment: "the busy gate only needs busy/not-busy to be right". The day plan is the first consumer that needs the wall clock to be right, and it does not get it.

Walk a 13:00 Moscow event on a box at UTC+04. parseDT stores Start = 13:00Z. mavcaldav writes the fact with Ts = e.Start. BuildPlan does at := e.At.In(now.Location()) and gets 17:00. She says "план на 01.08.2026: 17:00 — Standup @ 13:00-13:30". The prefix and the value disagree by four hours in the same line.

Now a 21:00 event. Ts = 21:00Z, which is 01:00 the next day locally. The key prefix is still calendar_event_20260801, so the SQL returns it, and then the at.Before(dayStart) || !at.Before(dayEnd) filter drops it. Every evening event east of Greenwich is silently missing from the day she recites.

Ambient events do not have this problem. EventFromNotification builds Start with time.Date(..., n.Posted.Location()), a real instant. So one plan mixes two clocks: relayed meetings land at the right time, CalDAV meetings are offset. The two sources are ordered against each other by sort.SliceStable, so the plan order is wrong too, not just the labels.

The fix belongs in parseDT, not here, but this PR is what makes it visible. At minimum the day plan should not ship claiming it recites the day.

2. A rescheduled meeting is recited twice.

store.CalendarEvents has no voids_id exclusion and no latest-per-key filter. It returns every historical row in the key range, ordered by key.

writeIfChanged in mavcaldav writes a new row when the value changes. Move a standup from 14:00 to 16:00. The key is unchanged, since FactKey keys on day plus summary. The store now holds two rows. The plan reads both: "14:00 — Standup @ 14:00-14:30; 16:00 — Standup @ 16:00-16:30". She reads back a meeting that no longer exists as if it were still on the day.

queryCalendar had the same exposure, but it prints only Value in a flat list, so a duplicate reads as noise. The plan prints a time per line, so a duplicate reads as a second appointment. Either dedupe by key at the top of dayPlan, keeping the newest row, or fix the query.

Related and cheap: FactValue already embeds 15:04-15:04, so every event line prints its time twice. "14:00 — Standup @ 14:00-14:30" is what the formatter produces today.

3. The doc says "any morning checklist still outstanding". The code says "active right now".

checklistEntries skips a routine unless st.Active. ipc.CoreAPI.DayPlan and the queryDayPlan comment both promise outstanding items, unqualified.

Ask "какие планы на сегодня?" at 14:00 with the morning routine unfinished. The window closed, Active is false, and the plan says nothing about it. The one thing the plan could tell him that the calendar cannot is exactly what he skipped. Either narrow the two doc comments to "checklist active now", or drop the Active test and keep the len(st.Missing) == 0 one.

The placement already makes the second option safe. The entry is timed at NudgeAt. An unfinished routine sorts to the top of the day, not to the moment of asking.

4. otherDayWords guards against the wrong days that ParseCalendarDate knows, not the wrong days he says.

The list covers сегодня-adjacent words only. "какие планы на понедельник?" has no other-day token and does contain "планы", so queryDayPlan claims it, ahead of queryCalendar in querySources. She answers today, stamped with today's date. Before this PR that utterance fell through to recall and got nothing, which is a better failure. Same for "планы на неделю" and "планы на выходные".

Weekday names, "неделя" and "выходные" in otherDayWords would close it. That is a refusal, not a feature, and refusing is correct until the plan can build any day but the clock's own.

Smaller notes:

  • isRestOfDayQuery uses strings.Contains while IsDayPlanQuery tokenizes deliberately. The two should agree, and the substring form is the one with the false positives.
  • When "что дальше?" arrives after the last item, After empties the plan and FormatRU says "на 01.08.2026 ничего не запланировано". That is false. The day had things, they are done. The rest-of-day render needs its own empty string.
  • dayPlan filters reminders on the string literal "pending". 7f42cc7 introduced DigestStatus for the same class of bug one table over.
  • dayPlanMaxReminders bounds the scan at 500 but ListReminders ordering is not stated at the call site. If it is not ordered by fire time, the truncation drops arbitrary reminders rather than distant ones.
  • safeKey strips Cyrillic, so two Russian-named events on one day collapse to the same key calendar_event_20260801_. The plan then shows one of them, or both as duplicates per finding 2. Pre-existing, but the plan is where he will notice it.

On cmd/mavend/actions_query.go: your comment about the thing you tried the day before is still unresolved, because you never named it. I am not proposing a day-plan redesign in its place. The question stays open and needs you to say what the thing was.

The split is right. `internal/morning/plan.go` is pure and the daemon owns every read, so the plan is testable at a fixed clock. Putting `Spoken` in the IPC payload means the voice reply and `/morning` cannot drift. `IsDayPlanQuery` tokenizes instead of substring-matching, and the comment about "планёрка" shows why that matters. The read-only claim holds: nothing in `tick.go:dayPlan` writes or dispatches. `7f42cc7` touches `cmd/mavend/actions_query.go` here, but only the weather branch. Nothing in this PR is fixed on the tip. **1. CalDAV events are recited at the wrong hour, and evening ones disappear.** `parseDT` reads a local DTSTART as UTC and says so in its own comment: "the busy gate only needs busy/not-busy to be right". The day plan is the first consumer that needs the wall clock to be right, and it does not get it. Walk a 13:00 Moscow event on a box at UTC+04. `parseDT` stores `Start = 13:00Z`. `mavcaldav` writes the fact with `Ts = e.Start`. `BuildPlan` does `at := e.At.In(now.Location())` and gets 17:00. She says "план на 01.08.2026: 17:00 — Standup @ 13:00-13:30". The prefix and the value disagree by four hours in the same line. Now a 21:00 event. `Ts = 21:00Z`, which is 01:00 the next day locally. The key prefix is still `calendar_event_20260801`, so the SQL returns it, and then the `at.Before(dayStart) || !at.Before(dayEnd)` filter drops it. Every evening event east of Greenwich is silently missing from the day she recites. Ambient events do not have this problem. `EventFromNotification` builds `Start` with `time.Date(..., n.Posted.Location())`, a real instant. So one plan mixes two clocks: relayed meetings land at the right time, CalDAV meetings are offset. The two sources are ordered against each other by `sort.SliceStable`, so the plan order is wrong too, not just the labels. The fix belongs in `parseDT`, not here, but this PR is what makes it visible. At minimum the day plan should not ship claiming it recites the day. **2. A rescheduled meeting is recited twice.** `store.CalendarEvents` has no `voids_id` exclusion and no latest-per-key filter. It returns every historical row in the key range, ordered by key. `writeIfChanged` in `mavcaldav` writes a new row when the value changes. Move a standup from 14:00 to 16:00. The key is unchanged, since `FactKey` keys on day plus summary. The store now holds two rows. The plan reads both: "14:00 — Standup @ 14:00-14:30; 16:00 — Standup @ 16:00-16:30". She reads back a meeting that no longer exists as if it were still on the day. `queryCalendar` had the same exposure, but it prints only `Value` in a flat list, so a duplicate reads as noise. The plan prints a time per line, so a duplicate reads as a second appointment. Either dedupe by key at the top of `dayPlan`, keeping the newest row, or fix the query. Related and cheap: `FactValue` already embeds `15:04-15:04`, so every event line prints its time twice. "14:00 — Standup @ 14:00-14:30" is what the formatter produces today. **3. The doc says "any morning checklist still outstanding". The code says "active right now".** `checklistEntries` skips a routine unless `st.Active`. `ipc.CoreAPI.DayPlan` and the `queryDayPlan` comment both promise outstanding items, unqualified. Ask "какие планы на сегодня?" at 14:00 with the morning routine unfinished. The window closed, `Active` is false, and the plan says nothing about it. The one thing the plan could tell him that the calendar cannot is exactly what he skipped. Either narrow the two doc comments to "checklist active now", or drop the `Active` test and keep the `len(st.Missing) == 0` one. The placement already makes the second option safe. The entry is timed at `NudgeAt`. An unfinished routine sorts to the top of the day, not to the moment of asking. **4. `otherDayWords` guards against the wrong days that `ParseCalendarDate` knows, not the wrong days he says.** The list covers сегодня-adjacent words only. "какие планы на понедельник?" has no other-day token and does contain "планы", so `queryDayPlan` claims it, ahead of `queryCalendar` in `querySources`. She answers today, stamped with today's date. Before this PR that utterance fell through to recall and got nothing, which is a better failure. Same for "планы на неделю" and "планы на выходные". Weekday names, "неделя" and "выходные" in `otherDayWords` would close it. That is a refusal, not a feature, and refusing is correct until the plan can build any day but the clock's own. Smaller notes: - `isRestOfDayQuery` uses `strings.Contains` while `IsDayPlanQuery` tokenizes deliberately. The two should agree, and the substring form is the one with the false positives. - When "что дальше?" arrives after the last item, `After` empties the plan and `FormatRU` says "на 01.08.2026 ничего не запланировано". That is false. The day had things, they are done. The rest-of-day render needs its own empty string. - `dayPlan` filters reminders on the string literal `"pending"`. `7f42cc7` introduced `DigestStatus` for the same class of bug one table over. - `dayPlanMaxReminders` bounds the scan at 500 but `ListReminders` ordering is not stated at the call site. If it is not ordered by fire time, the truncation drops arbitrary reminders rather than distant ones. - `safeKey` strips Cyrillic, so two Russian-named events on one day collapse to the same key `calendar_event_20260801_`. The plan then shows one of them, or both as duplicates per finding 2. Pre-existing, but the plan is where he will notice it. On `cmd/mavend/actions_query.go`: your comment about the thing you tried the day before is still unresolved, because you never named it. I am not proposing a day-plan redesign in its place. The question stays open and needs you to say what the thing was.
kami closed this pull request 2026-08-01 14:51:43 +02:00
Owner

Landed on master. The stack was one linear chain, so #84 carried every commit from #50 up, and master now contains this branch in full. Merging this PR on its own is an empty diff, so it is closed rather than merged. The review findings for it were fixed in the 2026-08-01 pass and are on master as commits on the stack tip, not on this branch.

Landed on master. The stack was one linear chain, so #84 carried every commit from #50 up, and master now contains this branch in full. Merging this PR on its own is an empty diff, so it is closed rather than merged. The review findings for it were fixed in the 2026-08-01 pass and are on master as commits on the stack tip, not on this branch.

Pull request closed

Sign in to join this conversation.
No Reviewers
No Label
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: kami/Maven#58