Merge branch 'fix/g04' into fix/integrated

# Conflicts:
#	cmd/mavend/actions_query.go
#	cmd/mavend/dayplan_test.go
This commit is contained in:
kami
2026-08-01 14:19:15 +04:00
20 changed files with 857 additions and 104 deletions
+25 -13
View File
@@ -66,7 +66,7 @@ func run(args []string) error {
if *url == "" || *user == "" || *pass == "" {
return fmt.Errorf("-url, -user, -pass are required")
}
if err := checkRenderTarget(*url, *renderURL); err != nil {
if err := checkRenderTarget([]string{*url}, *renderURL); err != nil {
return err
}
@@ -122,17 +122,26 @@ func run(args []string) error {
}
}
// checkRenderTarget refuses a render URL that is also a read URL. This is the
// structural half of #127's "cannot write to your work calendar": the write
// credential and the write URL are separate flags, and the one calendar maven
// is known to only read is rejected as a target at startup rather than trusted
// at runtime.
func checkRenderTarget(readURL, renderURL string) error {
// checkRenderTarget refuses a render URL that is also one of the read URLs.
// This is the structural half of #127's "cannot write to your work calendar":
// the write credential and the write URL are separate flags, and a calendar
// maven is known to only read is rejected as a target at startup rather than
// trusted at runtime.
//
// It takes the whole read set, not one URL. The guarantee in the package
// comment is about every calendar maven reads, and a second read target added
// later must not quietly fall outside the check.
func checkRenderTarget(readURLs []string, renderURL string) error {
if renderURL == "" {
return nil
}
if sameCollection(readURL, renderURL) {
return fmt.Errorf("-render-url must differ from -url: maven renders into a calendar she owns, never into one she reads")
for _, read := range readURLs {
if read == "" {
continue
}
if sameCollection(read, renderURL) {
return fmt.Errorf("-render-url must differ from the read URL %s: maven renders into a calendar she owns, never into one she reads", read)
}
}
return nil
}
@@ -163,7 +172,7 @@ func (p *poller) pollOnce(ctx context.Context) {
}
// Write calendar_busy on change.
if err := p.writeIfChanged(ctx, "calendar_busy", calendar.SourcePersonal, busyVal, now, 1.0); err != nil {
if err := p.writeIfChanged(ctx, "calendar_busy", calendar.SourcePersonal, busyVal, now); err != nil {
log.Printf("mavcaldav: write calendar_busy: %v", err)
return
}
@@ -173,7 +182,7 @@ func (p *poller) pollOnce(ctx context.Context) {
// reaching back to Radicale.
for _, e := range events {
key := calendar.FactKey(e)
if err := p.writeIfChanged(ctx, key, calendar.SourcePersonal, calendar.FactValue(e), e.Start, 1.0); err != nil {
if err := p.writeIfChanged(ctx, key, calendar.SourcePersonal, calendar.FactValue(e), e.Start); err != nil {
log.Printf("mavcaldav: write %s: %v", key, err)
}
}
@@ -206,7 +215,10 @@ func (p *poller) fetchEvents(ctx context.Context, now time.Time) ([]calendar.Eve
}
// writeIfChanged writes a fact only when the value differs from the latest.
func (p *poller) writeIfChanged(ctx context.Context, key, source, val string, ts time.Time, confidence float64) error {
// Everything this poller writes is a calendar read, which is full confidence by
// definition; a source that is not, such as the notification relay, does not
// come through here.
func (p *poller) writeIfChanged(ctx context.Context, key, source, val string, ts time.Time) error {
prev, err := p.core.LatestFactBySource(ctx, key, source)
switch {
case err == nil && prev.Value == val:
@@ -220,7 +232,7 @@ func (p *poller) writeIfChanged(ctx context.Context, key, source, val string, ts
Key: key,
Value: val,
Source: source,
Confidence: confidence,
Confidence: 1.0,
})
if err != nil {
return fmt.Errorf("write %s: %w", key, err)
+10 -8
View File
@@ -62,7 +62,7 @@ func TestWriteIfChanged(t *testing.T) {
t.Run("no previous fact writes", func(t *testing.T) {
fc := &fakeCore{}
p := &poller{core: fc}
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now, 1.0)
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -90,7 +90,7 @@ func TestWriteIfChanged(t *testing.T) {
},
}
p := &poller{core: fc}
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now, 1.0)
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -106,7 +106,7 @@ func TestWriteIfChanged(t *testing.T) {
},
}
p := &poller{core: fc}
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "new", now, 1.0)
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "new", now)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -121,7 +121,7 @@ func TestWriteIfChanged(t *testing.T) {
t.Run("read error other than ErrNoFact returns error", func(t *testing.T) {
fc := &fakeCore{readErr: fmt.Errorf("connection refused")}
p := &poller{core: fc}
err := p.writeIfChanged(ctx, "fail_key", "poll:caldav", "x", now, 1.0)
err := p.writeIfChanged(ctx, "fail_key", "poll:caldav", "x", now)
if err == nil {
t.Fatal("expected error, got nil")
}
@@ -133,7 +133,7 @@ func TestWriteIfChanged(t *testing.T) {
writeErr: fmt.Errorf("disk full"),
}
p := &poller{core: fc}
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now, 1.0)
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now)
if err == nil {
t.Fatal("expected error, got nil")
}
@@ -190,13 +190,15 @@ func TestPollOnce(t *testing.T) {
t.Errorf("calendar_busy ts is zero")
}
// Second write: calendar_event_<date>_<summary> = "<summary> @ HH:MM-HH:MM"
// Second write: calendar_event_<date>_<summary> = "<summary> @ HH:MM-HH:MM".
// The iCal states the event in UTC and the fact is stamped on the owner's
// clock, so the expected key date and times are the local reading of it.
eventReq := fc.writeLog[1]
expectedKey := "calendar_event_" + start.Format("20060102") + "_Current-meeting"
expectedKey := "calendar_event_" + start.Local().Format("20060102") + "_Current-meeting"
if eventReq.Key != expectedKey {
t.Errorf("event key = %q, want %q", eventReq.Key, expectedKey)
}
expectedVal := "Current meeting @ " + start.Format("15:04") + "-" + end.Format("15:04")
expectedVal := "Current meeting @ " + start.Local().Format("15:04") + "-" + end.Local().Format("15:04")
if eventReq.Value != expectedVal {
t.Errorf("event value = %q, want %q", eventReq.Value, expectedVal)
}
+80 -3
View File
@@ -2,15 +2,18 @@ package main
import (
"context"
"encoding/xml"
"fmt"
"io"
"log"
"net/http"
"net/url"
"strings"
"time"
"github.com/kami/maven/internal/calendar"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/store"
)
// renderer is the write half of maven's own local calendar (Vikunja #127).
@@ -38,6 +41,13 @@ type renderer struct {
// unchanged reminder costs nothing. Purely an optimisation: a restart
// re-publishes every reminder once, which is idempotent.
published map[int64]string
// reconciled — whether the collection has been read once since start. It
// has to be, because published is in-memory: withdrawal used to cover only
// the reminders THIS process published, so a reminder that fired while the
// daemon was down kept its event in the calendar forever, and nothing ever
// revisited it.
reconciled bool
}
func newRenderer(core ipc.CoreAPI, hc *http.Client, url, user, pass string, dur time.Duration) *renderer {
@@ -64,7 +74,7 @@ func (r *renderer) renderOnce(ctx context.Context) {
live := make(map[int64]bool, len(reminders))
for _, rem := range reminders {
if rem.Status != "pending" {
if rem.Status != store.ReminderPending {
continue
}
live[rem.ID] = true
@@ -81,10 +91,28 @@ func (r *renderer) renderOnce(ctx context.Context) {
log.Printf("mavcaldav: rendered reminder %d (%s)", rem.ID, e.Summary)
}
stale := make(map[int64]bool)
for id := range r.published {
if live[id] {
continue
if !live[id] {
stale[id] = true
}
}
if !r.reconciled {
remote, err := r.listPublished(ctx)
if err != nil {
// Try again next tick. A collection maven cannot read is not a
// reason to stop publishing to it.
log.Printf("mavcaldav: reconcile: %v", err)
} else {
r.reconciled = true
for _, id := range remote {
if !live[id] {
stale[id] = true
}
}
}
}
for id := range stale {
if err := r.delete(ctx, calendar.ReminderPath(id)); err != nil {
log.Printf("mavcaldav: withdraw reminder %d: %v", id, err)
continue
@@ -94,6 +122,55 @@ func (r *renderer) renderOnce(ctx context.Context) {
}
}
// listPublished PROPFINDs the collection and returns the reminder ids maven has
// events for in it. Only resources carrying calendar.ReminderUIDPrefix are
// reported, so a reconciliation pass can never propose deleting a file maven
// did not create — the same bound every other path in this file has.
func (r *renderer) listPublished(ctx context.Context) ([]int64, error) {
const body = `<?xml version="1.0" encoding="utf-8"?>` +
`<D:propfind xmlns:D="DAV:"><D:prop><D:resourcetype/></D:prop></D:propfind>`
req, err := http.NewRequestWithContext(ctx, "PROPFIND", r.url+"/", strings.NewReader(body))
if err != nil {
return nil, err
}
req.SetBasicAuth(r.user, r.pass)
req.Header.Set("Content-Type", "application/xml; charset=utf-8")
req.Header.Set("Depth", "1")
resp, err := r.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusMultiStatus && (resp.StatusCode < 200 || resp.StatusCode >= 300) {
return nil, fmt.Errorf("PROPFIND %s: %s", r.url, resp.Status)
}
var ms struct {
Responses []struct {
Href string `xml:"href"`
} `xml:"response"`
}
if err := xml.Unmarshal(raw, &ms); err != nil {
return nil, fmt.Errorf("PROPFIND %s: %w", r.url, err)
}
var ids []int64
for _, resp := range ms.Responses {
href, err := url.PathUnescape(strings.TrimSpace(resp.Href))
if err != nil {
continue
}
if id, ok := calendar.ReminderIDFromPath(href); ok {
ids = append(ids, id)
}
}
return ids, nil
}
// renderMaxReminders bounds the read. Reminders past this count are older than
// anything a calendar view is useful for.
const renderMaxReminders = 200
+125 -10
View File
@@ -2,9 +2,11 @@ package main
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"slices"
"strings"
"sync"
"testing"
@@ -27,12 +29,16 @@ func (c *reminderCore) ListReminders(context.Context, int) ([]ipc.Reminder, erro
return c.reminders, nil
}
// calSrv records what a CalDAV collection received.
// calSrv records what a CalDAV collection received. existing seeds resources
// that were already in the collection before this process started, which is
// what a restart looks like from the renderer's side.
type calSrv struct {
mu sync.Mutex
puts map[string]string
dels []string
status int
mu sync.Mutex
puts map[string]string
dels []string
existing []string
propfind int
status int
*httptest.Server
}
@@ -47,12 +53,43 @@ func newCalSrv() *calSrv {
s.puts[strings.TrimPrefix(r.URL.Path, "/cal/")] = string(body)
case http.MethodDelete:
s.dels = append(s.dels, strings.TrimPrefix(r.URL.Path, "/cal/"))
case "PROPFIND":
s.propfind++
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
w.WriteHeader(http.StatusMultiStatus)
io.WriteString(w, s.multistatusLocked(r.URL.Path))
return
}
w.WriteHeader(s.status)
}))
return s
}
// multistatusLocked renders the collection listing. Caller holds the lock.
func (s *calSrv) multistatusLocked(base string) string {
var b strings.Builder
b.WriteString(`<?xml version="1.0"?><D:multistatus xmlns:D="DAV:">`)
b.WriteString("<D:response><D:href>" + base + "</D:href></D:response>")
names := append([]string{}, s.existing...)
for name := range s.puts {
names = append(names, name)
}
for _, name := range names {
if slices.Contains(s.dels, name) {
continue
}
b.WriteString("<D:response><D:href>/cal/" + name + "</D:href></D:response>")
}
b.WriteString("</D:multistatus>")
return b.String()
}
func (s *calSrv) deleted() []string {
s.mu.Lock()
defer s.mu.Unlock()
return append([]string{}, s.dels...)
}
func (s *calSrv) putCount() int {
s.mu.Lock()
defer s.mu.Unlock()
@@ -168,19 +205,97 @@ func TestRenderOnceUsesNextFireForRecurring(t *testing.T) {
func TestCheckRenderTargetRefusesTheCalendarItReads(t *testing.T) {
read := "http://localhost:5232/kami/personal"
if err := checkRenderTarget(read, ""); err != nil {
if err := checkRenderTarget([]string{read}, ""); err != nil {
t.Fatalf("rendering off must be fine: %v", err)
}
if err := checkRenderTarget(read, "http://localhost:5232/kami/maven"); err != nil {
if err := checkRenderTarget([]string{read}, "http://localhost:5232/kami/maven"); err != nil {
t.Fatalf("a distinct collection must be accepted: %v", err)
}
if err := checkRenderTarget(read, read); err == nil {
if err := checkRenderTarget([]string{read}, read); err == nil {
t.Error("rendering into the read calendar must be refused")
}
if err := checkRenderTarget(read, read+"/"); err == nil {
if err := checkRenderTarget([]string{read}, read+"/"); err == nil {
t.Error("a trailing slash must not defeat the check")
}
if err := checkRenderTarget(read, strings.ToUpper(read)); err == nil {
if err := checkRenderTarget([]string{read}, strings.ToUpper(read)); err == nil {
t.Error("case must not defeat the check")
}
// Every read target is checked, not the first one. A second calendar to
// read must not fall outside the guarantee just by being added later.
work := "http://localhost:5232/kami/work"
if err := checkRenderTarget([]string{read, work}, work); err == nil {
t.Error("rendering into the second read calendar must be refused")
}
if err := checkRenderTarget([]string{read, work}, "http://localhost:5232/kami/maven"); err != nil {
t.Fatalf("a collection maven owns must still be accepted: %v", err)
}
}
// Withdrawal has to survive a restart. published is in-memory, so a fresh
// process knows nothing about the events an earlier one wrote: fire a reminder,
// restart mavcaldav, and its event used to sit in the collection forever
// because nothing ever revisited it. The first tick reads the collection and
// reconciles what it finds against what is pending.
func TestRenderOnceWithdrawsAfterRestart(t *testing.T) {
srv := newCalSrv()
defer srv.Close()
// Left behind by a previous process: 4 is still pending, 5 has fired.
// The third file is not maven's and must not be touched.
srv.existing = []string{"maven-reminder-4.ics", "maven-reminder-5.ics", "dentist.ics"}
core := &reminderCore{reminders: []ipc.Reminder{
{ID: 4, FireTs: time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC), Payload: "выпить воды", Status: "pending"},
{ID: 5, FireTs: time.Date(2026, 8, 1, 8, 0, 0, 0, time.UTC), Payload: "уже прозвенело", Status: "fired"},
}}
r := newRenderer(core, srv.Client(), srv.URL+"/cal", "u", "p", 0)
r.renderOnce(context.Background())
dels := srv.deleted()
if len(dels) != 1 || dels[0] != "maven-reminder-5.ics" {
t.Fatalf("deleted %v, want only the fired reminder's event", dels)
}
// The collection is read once, not on every tick.
r.renderOnce(context.Background())
srv.mu.Lock()
n := srv.propfind
srv.mu.Unlock()
if n != 1 {
t.Errorf("PROPFIND ran %d times, want once per process", n)
}
}
// A collection maven cannot read is not a reason to stop publishing to it, and
// the reconciliation must be retried rather than skipped for the process.
func TestRenderOnceRetriesReconcile(t *testing.T) {
srv := newCalSrv()
defer srv.Close()
srv.existing = []string{"maven-reminder-6.ics"}
failing := &http.Client{Transport: &propfindFailure{base: srv.Client().Transport}}
core := &reminderCore{}
r := newRenderer(core, failing, srv.URL+"/cal", "u", "p", 0)
r.renderOnce(context.Background())
if got := srv.deleted(); len(got) != 0 {
t.Fatalf("nothing can be withdrawn on a failed read: %v", got)
}
if r.reconciled {
t.Fatal("a failed read must not count as reconciled")
}
r.http = srv.Client()
r.renderOnce(context.Background())
if got := srv.deleted(); len(got) != 1 || got[0] != "maven-reminder-6.ics" {
t.Fatalf("deleted %v, want the orphaned event on the retry", got)
}
}
// propfindFailure fails PROPFIND and passes everything else through.
type propfindFailure struct{ base http.RoundTripper }
func (f *propfindFailure) RoundTrip(req *http.Request) (*http.Response, error) {
if req.Method == "PROPFIND" {
return nil, errors.New("collection unreachable")
}
return f.base.RoundTrip(req)
}
+7 -9
View File
@@ -140,11 +140,16 @@ func (h *reactiveHandler) queryFactByKey(ctx context.Context, t *queryTurn) (str
// queryDayPlan — "какие планы на сегодня?", "что у меня по плану?", "что
// дальше?" (Vikunja #128). Recites the day: calendar events, pending
// reminders, and any morning checklist still outstanding.
// reminders, and every morning checklist item today still has no evidence for,
// including the ones whose window has closed.
//
// 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.
//
// 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").
// The spoken recital here is the current answer, not the decided one.
func (h *reactiveHandler) queryDayPlan(ctx context.Context, t *queryTurn) (string, bool) {
if !router.IsDayPlanQuery(t.dec.Utterance) {
return "", false
@@ -154,7 +159,7 @@ func (h *reactiveHandler) queryDayPlan(ctx context.Context, t *queryTurn) (strin
log.Printf("voice: day plan: %v", err)
return "не получилось собрать план.", true
}
if !isRestOfDayQuery(t.dec.Utterance) {
if !router.IsRestOfDayQuery(t.dec.Utterance) {
return plan.Spoken, true
}
// Rebuild the pure plan so the rest-of-day rendering is the same code that
@@ -171,13 +176,6 @@ func (h *reactiveHandler) queryDayPlan(ctx context.Context, t *queryTurn) (strin
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")
}
// habitFactWindow — how many recent SELF facts the behaviour profile is counted
// over. Enough for a season of habits without scanning the whole store on every
// question; the profile is recomputed on read, so the bound is the cost control.
+84
View File
@@ -2,11 +2,13 @@ package main
import (
"context"
"database/sql"
"errors"
"strings"
"testing"
"time"
"github.com/kami/maven/internal/calendar"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/store"
@@ -86,12 +88,40 @@ func TestQueryDayPlanTrimsToRestOfDay(t *testing.T) {
}
}
// "что дальше?" after the last item of the day. The day was not empty, it is
// over, and the whole-day empty line says something false about a day he just
// lived through.
func TestQueryDayPlanRestOfDayWhenNothingIsLeft(t *testing.T) {
plan := samplePlan()
h := &reactiveHandler{api: &planAPI{plan: plan}, now: func() time.Time {
return time.Date(2026, 8, 3, 23, 0, 0, 0, time.UTC)
}}
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, plan.Date.Format("02.01.2006")) {
t.Errorf("the day had things on it and they are done, not empty: %q", reply)
}
if reply != "на сегодня больше ничего не запланировано." {
t.Errorf("reply = %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{
"что у меня сегодня?",
"какие планы на завтра?",
// The plan can only be built for the clock's own day. Naming another
// one has to fall through, not get answered with today.
"какие планы на понедельник?",
"какие планы на неделю?",
"какие планы на выходные?",
"what are my plans for friday?",
"когда планёрка?",
"какая погода?",
"",
@@ -267,3 +297,57 @@ func TestHabitQueryWithPlanWordReachesHabits(t *testing.T) {
t.Errorf("reply = %q, want %q", reply, want)
}
}
// The plan reads the store on the owner's clock: one line per event, the hour
// printed once, and reminders selected by fire time rather than by how
// recently they were stated.
func TestTickDayPlanReadsTheStore(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
tl := newTestTickLoop(t, st, &fakeSink{}, nil)
now := time.Date(2026, 8, 3, 12, 0, 0, 0, time.Local)
day := time.Date(2026, 8, 3, 0, 0, 0, 0, time.Local)
ev := calendar.Event{
Summary: "Standup",
Start: day.Add(14 * time.Hour),
End: day.Add(14*time.Hour + 30*time.Minute),
}
// Rescheduled: same key, a second row.
if _, err := st.WriteFact(ctx, ev.Start, store.KindEnv, calendar.FactKey(ev),
calendar.FactValue(ev), calendar.SourcePersonal, 1.0, sql.NullInt64{}); err != nil {
t.Fatalf("WriteFact: %v", err)
}
moved := ev
moved.Start, moved.End = day.Add(16*time.Hour), day.Add(16*time.Hour+30*time.Minute)
if _, err := st.WriteFact(ctx, moved.Start, store.KindEnv, calendar.FactKey(moved),
calendar.FactValue(moved), calendar.SourcePersonal, 1.0, sql.NullInt64{}); err != nil {
t.Fatalf("WriteFact: %v", err)
}
// One reminder today, one next year. Both are pending; only today's is a
// plan for today.
if _, err := st.CreateReminder(ctx, day.Add(18*time.Hour), "позвонить маме", ""); err != nil {
t.Fatalf("CreateReminder: %v", err)
}
if _, err := st.CreateReminder(ctx, day.AddDate(1, 0, 0), "продлить страховку", ""); err != nil {
t.Fatalf("CreateReminder: %v", err)
}
plan := tl.dayPlan(ctx, now)
if len(plan.Items) != 2 {
t.Fatalf("got %d items, want the moved standup and today's reminder: %+v", len(plan.Items), plan.Items)
}
ev0 := plan.Items[0]
if ev0.Kind != "event" || ev0.At.In(time.Local).Format("15:04") != "16:00" {
t.Errorf("event = %+v, want the 16:00 one", ev0)
}
if ev0.Text != "Standup" {
t.Errorf("text = %q — the plan prints the hour itself", ev0.Text)
}
if plan.Items[1].Text != "позвонить маме" {
t.Errorf("second item = %+v", plan.Items[1])
}
if strings.Contains(plan.Spoken, "страховку") {
t.Errorf("a reminder for next year is not today's plan: %q", plan.Spoken)
}
}
+8 -9
View File
@@ -19,6 +19,7 @@ import (
"sync"
"time"
"github.com/kami/maven/internal/calendar"
"github.com/kami/maven/internal/config"
"github.com/kami/maven/internal/delivery"
"github.com/kami/maven/internal/ipc"
@@ -825,8 +826,10 @@ func (t *tickLoop) dayPlan(ctx context.Context, now time.Time) ipc.DayPlan {
}
for _, f := range facts {
events = append(events, morning.PlanEntry{
At: f.Ts,
Text: f.Value,
At: f.Ts,
// The plan prints the hour itself, so the "@ 14:00-14:30" tail the
// fact value carries would say it twice.
Text: calendar.FactSummary(f.Value),
Kind: morning.PlanEvent,
// Provenance below a calendar read (an ambient relay, #126) is
// hedged rather than recited as fact.
@@ -835,12 +838,12 @@ func (t *tickLoop) dayPlan(ctx context.Context, now time.Time) ipc.DayPlan {
}
var reminders []morning.PlanEntry
rems, err := t.store.ListReminders(ctx, dayPlanMaxReminders)
rems, err := t.store.PendingReminders(ctx, dayStart, dayEnd)
if err != nil {
log.Printf("tick: day plan: list reminders: %v", err)
log.Printf("tick: day plan: pending reminders: %v", err)
}
for _, r := range rems {
if r.Status != "pending" {
if r.Status != store.ReminderPending {
continue
}
fire := r.NextFireTs
@@ -873,10 +876,6 @@ func (t *tickLoop) dayPlan(ctx context.Context, now time.Time) ipc.DayPlan {
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:
+4 -1
View File
@@ -55,7 +55,10 @@ func meetingNotification() calendar.Notification {
Package: "com.google.android.gm",
Title: "Планёрка",
Text: "10:00-10:30",
Posted: time.Date(2026, 8, 3, 9, 40, 0, 0, time.UTC),
// Local, like a phone relaying from the box's own timezone: the fact
// key and value are stamped on the owner's clock, so a UTC reading
// here would only be testing the offset of the test machine.
Posted: time.Date(2026, 8, 3, 9, 40, 0, 0, time.Local),
}
}