diff --git a/cmd/mavcaldav/main.go b/cmd/mavcaldav/main.go index 1417ed0..4c120bd 100644 --- a/cmd/mavcaldav/main.go +++ b/cmd/mavcaldav/main.go @@ -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) diff --git a/cmd/mavcaldav/main_test.go b/cmd/mavcaldav/main_test.go index 30c46dc..c3d82d3 100644 --- a/cmd/mavcaldav/main_test.go +++ b/cmd/mavcaldav/main_test.go @@ -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__ = " @ HH:MM-HH:MM" + // Second write: calendar_event__ = " @ 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) } diff --git a/cmd/mavcaldav/render.go b/cmd/mavcaldav/render.go index ee54e2d..4568317 100644 --- a/cmd/mavcaldav/render.go +++ b/cmd/mavcaldav/render.go @@ -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 = `` + + `` + 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 diff --git a/cmd/mavcaldav/render_test.go b/cmd/mavcaldav/render_test.go index 2ba0f07..53c759d 100644 --- a/cmd/mavcaldav/render_test.go +++ b/cmd/mavcaldav/render_test.go @@ -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(``) + b.WriteString("" + base + "") + 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("/cal/" + name + "") + } + b.WriteString("") + 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) } diff --git a/cmd/mavend/actions_query.go b/cmd/mavend/actions_query.go index 4b61170..fb244c8 100644 --- a/cmd/mavend/actions_query.go +++ b/cmd/mavend/actions_query.go @@ -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. diff --git a/cmd/mavend/dayplan_test.go b/cmd/mavend/dayplan_test.go index 308c362..2ae4e8a 100644 --- a/cmd/mavend/dayplan_test.go +++ b/cmd/mavend/dayplan_test.go @@ -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) + } +} diff --git a/cmd/mavend/tick.go b/cmd/mavend/tick.go index d1c6eac..922e8a0 100644 --- a/cmd/mavend/tick.go +++ b/cmd/mavend/tick.go @@ -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: diff --git a/cmd/mavweb/ambient_test.go b/cmd/mavweb/ambient_test.go index 4067d3f..1636a1b 100644 --- a/cmd/mavweb/ambient_test.go +++ b/cmd/mavweb/ambient_test.go @@ -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), } } diff --git a/internal/calendar/calendar.go b/internal/calendar/calendar.go index 01a683d..829a9e0 100644 --- a/internal/calendar/calendar.go +++ b/internal/calendar/calendar.go @@ -72,15 +72,56 @@ type Event struct { // across polls so re-reading an unchanged calendar rewrites nothing. // // The date prefix is load-bearing — store.CalendarEvents selects a day range -// by key prefix, not by a timestamp column. -func FactKey(e Event) string { - return fmt.Sprintf("calendar_event_%s_%s", e.Start.Format("20060102"), safeKey(e.Summary)) +// by key prefix, not by a timestamp column — and it is the OWNER's day, taken +// on the box clock. An event carries the zone its server stated it in, so +// keying off the event's own location would file a 21:00 Moscow meeting under +// a different date than the day plan asks for. +func FactKey(e Event) string { return FactKeyIn(e, time.Local) } + +// FactKeyIn is FactKey against an explicit location. +func FactKeyIn(e Event, loc *time.Location) string { + return fmt.Sprintf("calendar_event_%s_%s", e.Start.In(loc).Format("20060102"), safeKey(e.Summary)) } // FactValue is the human-readable rendering stored as the fact value, and the -// string the day plan and the query path read back. -func FactValue(e Event) string { - return fmt.Sprintf("%s @ %s-%s", e.Summary, e.Start.Format("15:04"), e.End.Format("15:04")) +// string the day plan and the query path read back. Times are the owner's wall +// clock, for the same reason the key date is. +func FactValue(e Event) string { return FactValueIn(e, time.Local) } + +// FactValueIn is FactValue against an explicit location. +func FactValueIn(e Event, loc *time.Location) string { + return fmt.Sprintf("%s @ %s-%s", e.Summary, e.Start.In(loc).Format("15:04"), e.End.In(loc).Format("15:04")) +} + +// FactSummary strips the "@ HH:MM-HH:MM" tail FactValue appends, for a caller +// that prints the time itself. The day plan does: without this it renders +// "14:00 — Standup @ 14:00-14:30" and says the hour twice. +func FactSummary(value string) string { + i := strings.LastIndex(value, " @ ") + if i < 0 { + return value + } + tail := value[i+len(" @ "):] + if len(tail) != len("15:04-15:04") { + return value + } + for j, r := range tail { + switch j { + case 2, 8: + if r != ':' { + return value + } + case 5: + if r != '-' { + return value + } + default: + if r < '0' || r > '9' { + return value + } + } + } + return value[:i] } // KeyPrefixForDay is the fact-key prefix covering one calendar day. The store diff --git a/internal/calendar/calendar_test.go b/internal/calendar/calendar_test.go index 31d3047..a7ffdbb 100644 --- a/internal/calendar/calendar_test.go +++ b/internal/calendar/calendar_test.go @@ -78,11 +78,13 @@ func TestParseICalDayUsesOwnersDay(t *testing.T) { func TestParseVEVENT(t *testing.T) { block := "DTSTART;TZID=Europe/Moscow:20260703T130000\nDTEND:20260703T140000Z\nSUMMARY:Stand up meeting" - e, ok := parseVEVENT(block) + e, ok := parseVEVENT(block, time.UTC) if !ok { t.Fatal("expected a parsed event") } - if !e.Start.Equal(time.Date(2026, 7, 3, 13, 0, 0, 0, time.UTC)) { + // 13:00 Moscow is 10:00Z. Reading it as 13:00Z is the bug that put the + // event three hours late in the day plan. + if !e.Start.Equal(time.Date(2026, 7, 3, 10, 0, 0, 0, time.UTC)) { t.Errorf("start = %v", e.Start) } if !e.End.Equal(time.Date(2026, 7, 3, 14, 0, 0, 0, time.UTC)) { @@ -93,26 +95,34 @@ func TestParseVEVENT(t *testing.T) { } allDay := "DTSTART;VALUE=DATE:20260703\nDTEND;VALUE=DATE:20260704\nSUMMARY:All-day" - if _, ok := parseVEVENT(allDay); ok { + if _, ok := parseVEVENT(allDay, time.UTC); ok { t.Error("all-day event should be rejected") } } func TestParseDT(t *testing.T) { + plus4 := time.FixedZone("+04", 4*60*60) tests := []struct { name string line string + loc *time.Location want time.Time wantOK bool }{ - {"UTC", "DTEND:20260703T100000Z", time.Date(2026, 7, 3, 10, 0, 0, 0, time.UTC), true}, - {"local", "DTSTART;TZID=Europe/Moscow:20260703T130000", time.Date(2026, 7, 3, 13, 0, 0, 0, time.UTC), true}, - {"all-day", "DTSTART;VALUE=DATE:20260703", time.Time{}, false}, - {"garbage", "DTSTART:garbage", time.Time{}, false}, + {"UTC", "DTEND:20260703T100000Z", plus4, time.Date(2026, 7, 3, 10, 0, 0, 0, time.UTC), true}, + {"tzid", "DTSTART;TZID=Europe/Moscow:20260703T130000", plus4, time.Date(2026, 7, 3, 10, 0, 0, 0, time.UTC), true}, + {"tzid quoted", `DTSTART;TZID="Europe/Moscow":20260703T130000`, plus4, time.Date(2026, 7, 3, 10, 0, 0, 0, time.UTC), true}, + {"tzid with other params", "DTSTART;VALUE=DATE-TIME;TZID=Asia/Tokyo:20260703T130000", plus4, time.Date(2026, 7, 3, 4, 0, 0, 0, time.UTC), true}, + // An unloadable zone falls back to the reader's own clock, not to UTC. + {"unknown tzid", "DTSTART;TZID=Mars/Olympus:20260703T130000", plus4, time.Date(2026, 7, 3, 13, 0, 0, 0, plus4), true}, + // Floating: no Z, no TZID. Local to whoever reads it. + {"floating", "DTSTART:20260703T130000", plus4, time.Date(2026, 7, 3, 13, 0, 0, 0, plus4), true}, + {"all-day", "DTSTART;VALUE=DATE:20260703", plus4, time.Time{}, false}, + {"garbage", "DTSTART:garbage", plus4, time.Time{}, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, ok := parseDT(tt.line) + got, ok := parseDT(tt.line, tt.loc) if ok != tt.wantOK { t.Errorf("ok = %v, want %v", ok, tt.wantOK) } @@ -143,20 +153,73 @@ func TestFactKeyAndValue(t *testing.T) { Start: time.Date(2026, 7, 3, 14, 0, 0, 0, time.UTC), End: time.Date(2026, 7, 3, 15, 0, 0, 0, time.UTC), } - if got, want := FactKey(e), "calendar_event_20260703_Team-sync"; got != want { + if got, want := FactKeyIn(e, time.UTC), "calendar_event_20260703_Team-sync"; got != want { t.Errorf("FactKey = %q, want %q", got, want) } - if got, want := FactValue(e), "Team sync @ 14:00-15:00"; got != want { + if got, want := FactValueIn(e, time.UTC), "Team sync @ 14:00-15:00"; got != want { t.Errorf("FactValue = %q, want %q", got, want) } if got, want := KeyPrefixForDay(e.Start), "calendar_event_20260703"; got != want { t.Errorf("KeyPrefixForDay = %q, want %q", got, want) } - if !strings.HasPrefix(FactKey(e), KeyPrefixForDay(e.Start)) { + if !strings.HasPrefix(FactKeyIn(e, time.UTC), KeyPrefixForDay(e.Start)) { t.Error("FactKey must start with the day prefix the store range-scans on") } } +// The key date and the printed time are the owner's, not the calendar +// server's. A 23:00 Moscow event read on a +04 box belongs to the next local +// day, and filing it under the Moscow day would hide it from the day plan the +// store range-scans for. +func TestFactKeyAndValueUseTheOwnersClock(t *testing.T) { + msk := time.FixedZone("MSK", 3*60*60) + plus4 := time.FixedZone("+04", 4*60*60) + e := Event{ + Summary: "Late sync", + Start: time.Date(2026, 7, 3, 23, 30, 0, 0, msk), + End: time.Date(2026, 7, 4, 0, 30, 0, 0, msk), + } + if got, want := FactKeyIn(e, plus4), "calendar_event_20260704_Late-sync"; got != want { + t.Errorf("FactKeyIn = %q, want %q", got, want) + } + if got, want := FactValueIn(e, plus4), "Late sync @ 00:30-01:30"; got != want { + t.Errorf("FactValueIn = %q, want %q", got, want) + } +} + +func TestFactSummaryDropsTheTimeTail(t *testing.T) { + if got, want := FactSummary("Standup @ 14:00-14:30"), "Standup"; got != want { + t.Errorf("FactSummary = %q, want %q", got, want) + } + // Nothing that is not the exact tail FactValue writes is touched. + for _, in := range []string{"Coffee @ home", "Standup", "Standup @ 14:00-14:3", "Standup @ 1a:00-14:30"} { + if got := FactSummary(in); got != in { + t.Errorf("FactSummary(%q) = %q, want it unchanged", in, got) + } + } +} + +// Regression for the mirror image of the window bug: the window is local, so +// the event must be a real instant too. A 22:00 event stated in the poller's +// own zone used to parse as 22:00Z, which on a +03 box is past the end of the +// local day, and the whole evening dropped out of both the busy gate and the +// day plan. +func TestParseICalDayKeepsTheEveningInAZonedCalendar(t *testing.T) { + plus3 := time.FixedZone("+03", 3*60*60) + now := time.Date(2026, 8, 1, 12, 0, 0, 0, plus3) + body := []byte("BEGIN:VCALENDAR\nBEGIN:VEVENT\n" + + "DTSTART;TZID=Europe/Moscow:20260801T220000\nDTEND;TZID=Europe/Moscow:20260801T230000\n" + + "SUMMARY:Evening call\nEND:VEVENT\nEND:VCALENDAR") + + events := ParseICalDay(body, now) + if len(events) != 1 { + t.Fatalf("got %d events, want the evening one", len(events)) + } + if got := events[0].Start.In(plus3).Format("15:04"); got != "22:00" { + t.Errorf("start reads %s locally, want 22:00", got) + } +} + func TestBusyAndOverlapping(t *testing.T) { base := time.Date(2026, 7, 3, 0, 0, 0, 0, time.UTC) events := []Event{ diff --git a/internal/calendar/ical.go b/internal/calendar/ical.go index c7617f7..1aa40de 100644 --- a/internal/calendar/ical.go +++ b/internal/calendar/ical.go @@ -4,12 +4,22 @@ import ( "fmt" "strings" "time" + + // The TZID of a DTSTART names an IANA zone, and resolving it needs the zone + // database. The deploy image has no system tzdata, so embed it: without it + // every zoned event would silently fall back to the box's own offset, which + // is the bug this package had before. + _ "time/tzdata" ) // ParseICal scans iCal text for VEVENT components and returns the events // overlapping [from, to). All-day events are skipped: parseDT reports no time // for a VALUE=DATE value, and an event with no clock reading answers neither // the busy gate nor the day plan. +// +// from's location is the fallback zone for a floating DTSTART — one with +// neither a Z suffix nor a TZID. RFC 5545 says a floating time is local to +// wherever it is read, and here that is the box the poller runs on. func ParseICal(body []byte, from, to time.Time) []Event { var events []Event text := string(body) @@ -26,7 +36,7 @@ func ParseICal(body []byte, from, to time.Time) []Event { block := text[:j] text = text[j+len("END:VEVENT"):] - e, ok := parseVEVENT(block) + e, ok := parseVEVENT(block, from.Location()) if !ok { continue } @@ -40,11 +50,12 @@ func ParseICal(body []byte, from, to time.Time) []Event { // ParseICalDay is ParseICal over the calendar day containing now, in now's own // location — the window cmd/mavcaldav polls. // -// The location matters. The old inline version took the day number off a local -// clock reading but built the boundaries in UTC, so east of Greenwich the -// window was shifted by the offset and part of the evening fell outside -// "today": on a +04 box after 20:00 UTC the poller saw an empty calendar. The -// owner's day is the day the day plan and the busy gate mean. +// The location matters, on both sides of the comparison. An older version took +// the day number off a local clock reading but built the boundaries in UTC, so +// east of Greenwich the window was shifted by the offset and part of the +// evening fell outside "today". Building the window locally is only half the +// fix: parseDT used to stamp a zoned DTSTART as UTC, which lost the mirror +// image of the same evening. Both sides are real instants now. func ParseICalDay(body []byte, now time.Time) []Event { y, m, d := now.Date() start := time.Date(y, m, d, 0, 0, 0, 0, now.Location()) @@ -53,17 +64,17 @@ func ParseICalDay(body []byte, now time.Time) []Event { // parseVEVENT extracts UID, start, end and summary from a VEVENT block. // Reports false for all-day events and parse failures. -func parseVEVENT(block string) (Event, bool) { +func parseVEVENT(block string, loc *time.Location) (Event, bool) { var e Event for _, line := range strings.Split(block, "\n") { line = strings.TrimSpace(line) switch { case strings.HasPrefix(line, "DTSTART"): - if t, ok := parseDT(line); ok { + if t, ok := parseDT(line, loc); ok { e.Start = t } case strings.HasPrefix(line, "DTEND"): - if t, ok := parseDT(line); ok { + if t, ok := parseDT(line, loc); ok { e.End = t } case strings.HasPrefix(line, "SUMMARY"): @@ -85,16 +96,21 @@ func afterColon(line string) string { return "" } -// parseDT parses a DTSTART/DTEND value: +// parseDT parses a DTSTART/DTEND value into a real instant: // // - UTC: DTEND:20260703T100000Z -// - Local: DTSTART;TZID=Europe/Moscow:20260703T130000 -// - All-day: DTSTART;VALUE=DATE:20260703 (rejected) +// - Zoned: DTSTART;TZID=Europe/Moscow:20260703T130000 +// - Floating: DTSTART:20260703T130000 (read in loc) +// - All-day: DTSTART;VALUE=DATE:20260703 (rejected) // -// A local time is read as UTC, the behaviour cmd/mavcaldav has always had: the -// CalDAV server and the poller run in the same timezone, and the busy gate only -// needs busy/not-busy to be right. -func parseDT(line string) (time.Time, bool) { +// A zoned value is resolved against its own TZID, not stamped as UTC. The old +// behaviour was "the server and the poller share a timezone, and the busy gate +// only needs busy/not-busy to be right", and that stopped being enough when the +// day plan started reciting the wall clock: a 13:00 Moscow meeting read as +// 13:00Z was recited at 17:00 on a +04 box, and a 21:00 one fell out of the day +// altogether. An unknown or unloadable TZID falls back to loc, which is the +// closest thing to the reader's own wall clock we have. +func parseDT(line string, loc *time.Location) (time.Time, bool) { if strings.Contains(line, "VALUE=DATE:") { return time.Time{}, false } @@ -102,12 +118,39 @@ func parseDT(line string) (time.Time, bool) { if i < 0 { return time.Time{}, false } - val := strings.TrimSuffix(strings.TrimSpace(line[i+1:]), "Z") - t, err := time.Parse("20060102T150405", val) + if loc == nil { + loc = time.UTC + } + raw := strings.TrimSpace(line[i+1:]) + if strings.HasSuffix(raw, "Z") { + t, err := time.ParseInLocation("20060102T150405", strings.TrimSuffix(raw, "Z"), time.UTC) + if err != nil { + return time.Time{}, false + } + return t, true + } + if tz := tzidOf(line[:i]); tz != "" { + if l, err := time.LoadLocation(tz); err == nil { + loc = l + } + } + t, err := time.ParseInLocation("20060102T150405", raw, loc) if err != nil { return time.Time{}, false } - return t.UTC(), true + return t, true +} + +// tzidOf pulls the TZID out of a property's parameter list ("DTSTART;TZID=..." +// up to the value colon). The value may be quoted, per RFC 5545 param syntax. +func tzidOf(params string) string { + for _, p := range strings.Split(params, ";")[1:] { + if !strings.HasPrefix(strings.ToUpper(p), "TZID=") { + continue + } + return strings.Trim(strings.TrimSpace(p[len("TZID="):]), `"`) + } + return "" } // RenderICal wraps events in a VCALENDAR body suitable for PUTting to a CalDAV diff --git a/internal/calendar/reminder.go b/internal/calendar/reminder.go index 86356f2..0847c54 100644 --- a/internal/calendar/reminder.go +++ b/internal/calendar/reminder.go @@ -2,6 +2,7 @@ package calendar import ( "fmt" + "strconv" "strings" "time" ) @@ -43,3 +44,26 @@ func ReminderEvent(id int64, fire time.Time, payload string, dur time.Duration) func ReminderPath(id int64) string { return fmt.Sprintf("%s%d.ics", ReminderUIDPrefix, id) } + +// ReminderIDFromPath reads back what ReminderPath wrote, given an href out of a +// PROPFIND. It reports false for anything that is not a resource maven +// published, which is what keeps a reconciliation pass from touching a file it +// did not create. +func ReminderIDFromPath(href string) (int64, bool) { + name := href + if i := strings.LastIndex(name, "/"); i >= 0 { + name = name[i+1:] + } + if !strings.HasPrefix(name, ReminderUIDPrefix) || !strings.HasSuffix(name, ".ics") { + return 0, false + } + digits := name[len(ReminderUIDPrefix) : len(name)-len(".ics")] + if digits == "" { + return 0, false + } + id, err := strconv.ParseInt(digits, 10, 64) + if err != nil || id <= 0 { + return 0, false + } + return id, true +} diff --git a/internal/morning/morning.go b/internal/morning/morning.go index 8a94db7..beabb0b 100644 --- a/internal/morning/morning.go +++ b/internal/morning/morning.go @@ -140,6 +140,29 @@ func Evaluate(r Routine, facts map[string]store.Fact, now time.Time) Status { return st } +// Outstanding reports the items of a routine that today has no evidence for, +// whether or not the window is still open. Evaluate answers "what is missing +// right now" and goes silent the moment the window closes; the day plan asks a +// different question, "what did today still not get done", and a skipped +// routine is exactly what it is worth telling him. Nothing before the window +// opens is outstanding yet, so the morning routine is not a complaint at 06:00. +func Outstanding(r Routine, facts map[string]store.Fact, now time.Time) []Item { + if !appliesToday(r, now) { + return nil + } + start, ok := todayAt(r.WindowStart, now) + if !ok || now.Before(start) { + return nil + } + var missing []Item + for _, it := range r.Items { + if !evidenced(it, facts, start, now) { + missing = append(missing, it) + } + } + return missing +} + // Due returns the routines that have reached their nudge time today with at // least one item still missing, and records `now` in `last` for each one // returned so it fires at most once per calendar day. The caller owns diff --git a/internal/morning/plan.go b/internal/morning/plan.go index 530a33a..ba2c699 100644 --- a/internal/morning/plan.go +++ b/internal/morning/plan.go @@ -48,10 +48,13 @@ type PlanEntry struct { Uncertain bool } -// Plan — the ordered day. Date is the calendar day it describes. +// 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. type Plan struct { Date time.Time Items []PlanEntry + Rest bool } // BuildPlan orders everything known about the day Now falls on: calendar @@ -98,17 +101,23 @@ func BuildPlan(routines []Routine, facts map[string]store.Fact, events, reminder // 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 +// A routine that does not apply today, has not opened yet, or is already // complete contributes nothing: the plan says what is left, not what was done. +// +// A closed window still counts. Asked at 14:00 with the morning routine +// unfinished, the plan used to say nothing about it, because Evaluate reports +// Active only inside the window. What he skipped is the one thing the plan can +// tell him that the calendar cannot, and the entry sorts to its nudge time, not +// to the moment of asking. 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 { + missing := Outstanding(r, facts, now) + if len(missing) == 0 { continue } - labels := make([]string, 0, len(st.Missing)) - for _, it := range st.Missing { + labels := make([]string, 0, len(missing)) + for _, it := range missing { label := it.Label if label == "" { label = it.Key @@ -136,7 +145,7 @@ func checklistEntries(routines []Routine, facts map[string]store.Fact, now time. // "что дальше?" 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} + out := Plan{Date: p.Date, Rest: true} for _, it := range p.Items { if it.At.Before(now) { continue @@ -151,6 +160,12 @@ func (p Plan) After(now time.Time) Plan { // she does not tell him to get on with it. func (p Plan) FormatRU() string { if len(p.Items) == 0 { + // "что дальше?" after the last item of the day. The day was not empty, + // it is over, and saying it was empty is a false statement about a day + // he just lived. + if p.Rest { + return "на сегодня больше ничего не запланировано." + } return fmt.Sprintf("на %s ничего не запланировано.", p.Date.Format("02.01.2006")) } parts := make([]string, len(p.Items)) diff --git a/internal/morning/plan_test.go b/internal/morning/plan_test.go index 35ff4d1..c7b282e 100644 --- a/internal/morning/plan_test.go +++ b/internal/morning/plan_test.go @@ -163,7 +163,55 @@ func TestPlanAfter(t *testing.T) { if len(empty.Items) != 0 { t.Errorf("got %+v", empty.Items) } - if !strings.Contains(empty.FormatRU(), "ничего не запланировано") { - t.Errorf("empty plan reads %q", empty.FormatRU()) + // An empty rest-of-day is not an empty day. Saying "на 03.08.2026 ничего + // не запланировано" at 23:00 denies the day he just lived. + if got, want := empty.FormatRU(), "на сегодня больше ничего не запланировано."; got != want { + t.Errorf("empty rest-of-day reads %q, want %q", got, want) + } +} + +// 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 +// tell him that the calendar cannot went silent at 11:00. +func TestBuildPlanKeepsAClosedWindowOutstanding(t *testing.T) { + now := time.Date(2026, 8, 3, 14, 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)}} + + p := BuildPlan(routines, facts, nil, nil, now) + if len(p.Items) != 1 { + t.Fatalf("got %+v, want the unfinished morning routine", p.Items) + } + it := p.Items[0] + if it.Kind != PlanChecklist { + t.Errorf("kind = %q", it.Kind) + } + // Placed at the nudge time, so it sorts to the top of the day rather than + // to the moment of asking. + if got := it.At.Format("15:04"); got != "10:30" { + t.Errorf("placed at %s, want 10:30", got) + } + if !strings.Contains(it.Text, "витамины") || strings.Contains(it.Text, "выпить воды") { + t.Errorf("line = %q", it.Text) + } +} + +// A routine whose window has not opened yet is not outstanding. Nothing has +// been skipped at 06:00. +func TestBuildPlanIgnoresAnUnopenedWindow(t *testing.T) { + now := time.Date(2026, 8, 3, 6, 0, 0, 0, time.UTC) + routines := []Routine{{ + Name: "утро", WindowStart: "07:00", WindowEnd: "11:00", + Items: []Item{{Key: "water", FactKey: "drank_water", Label: "выпить воды"}}, + }} + if p := BuildPlan(routines, nil, nil, nil, now); len(p.Items) != 0 { + t.Fatalf("got %+v", p.Items) } } diff --git a/internal/router/calendar.go b/internal/router/calendar.go index e6cf6bf..de182d0 100644 --- a/internal/router/calendar.go +++ b/internal/router/calendar.go @@ -29,13 +29,26 @@ 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. +// otherDayWords — a span that is not the clock's own day. The plan can only be +// built for today, so an utterance naming another day, a weekday, a week or a +// weekend belongs to the calendar listing instead. Claiming it here would +// answer today and stamp it with today's date, which is a wrong answer where +// falling through is only a terse one. +// +// The weekday names are here as a refusal, not as a feature. "какие планы на +// понедельник?" carries no other-day token in the сегодня family and does carry +// "планы", so the plan used to claim it and recite today. var otherDayWords = []string{ "завтра", "послезавтра", "вчера", "позавчера", "tomorrow", "yesterday", + "понедельник", "вторник", "среду", "среда", "четверг", "пятницу", "пятница", + "субботу", "суббота", "воскресенье", + "понедельника", "вторника", "четверга", "пятницы", "субботы", "воскресенья", + "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday", + "неделю", "неделя", "недели", "неделе", + "выходные", "выходных", "выходным", + "месяц", "месяца", "месяце", + "week", "weekend", "month", } // IsDayPlanQuery reports whether an utterance asks for today's plan (Vikunja @@ -76,6 +89,18 @@ func IsDayPlanQuery(text string) bool { (hasTok(toks, "what") && hasTok(toks, "next")) } +// IsRestOfDayQuery reports whether the utterance asks for what is left of the +// day rather than for the whole of it — "что дальше?" and its English form. +// +// Tokenized for the same reason IsDayPlanQuery is: the substring form matched +// "дальше" inside longer words and "next" inside "nextcloud", and the two +// predicates deciding the same utterance differently is worse than either +// being wrong on its own. +func IsRestOfDayQuery(text string) bool { + toks := planTokens(text) + return hasTok(toks, "дальше") || hasTok(toks, "next") +} + func hasTok(toks []string, w string) bool { for _, t := range toks { if t == w { diff --git a/internal/router/calendar_test.go b/internal/router/calendar_test.go index 4772733..7937a94 100644 --- a/internal/router/calendar_test.go +++ b/internal/router/calendar_test.go @@ -81,3 +81,38 @@ func TestIsDayPlanQuery(t *testing.T) { } } } + +// The plan is built for the clock's own day. A weekday, a week or a weekend +// carries no сегодня-family token, so the plan used to claim the utterance and +// recite today under today's date. Refusing is the right answer until the plan +// can build a day that is not the clock's own. +func TestIsDayPlanQueryRefusesOtherSpans(t *testing.T) { + for _, s := range []string{ + "какие планы на понедельник?", + "планы на пятницу", + "какие планы на неделю?", + "планы на выходные", + "какие планы на месяц?", + "what are my plans for friday?", + "my plan for the week", + } { + if IsDayPlanQuery(s) { + t.Errorf("IsDayPlanQuery(%q) = true, want false", s) + } + } +} + +// The rest-of-day test tokenizes like IsDayPlanQuery does. The substring form +// it replaced fired on any word containing "next" or "дальше". +func TestIsRestOfDayQuery(t *testing.T) { + for _, s := range []string{"что дальше?", "и что потом, дальше?", "what's next", "NEXT"} { + if !IsRestOfDayQuery(s) { + t.Errorf("IsRestOfDayQuery(%q) = false, want true", s) + } + } + for _, s := range []string{"какие планы на сегодня?", "проверь nextcloud", "дальшесъезд", ""} { + if IsRestOfDayQuery(s) { + t.Errorf("IsRestOfDayQuery(%q) = true, want false", s) + } + } +} diff --git a/internal/store/facts.go b/internal/store/facts.go index 3ff9075..ffdfd43 100644 --- a/internal/store/facts.go +++ b/internal/store/facts.go @@ -129,6 +129,13 @@ func (s *Store) RecentActiveFactsByKind(ctx context.Context, kind FactKind, n in // the same answer. The source stays on each Fact, along with its confidence, so // the caller can hedge a reading it did not get from a calendar server — // filtering by source here would have thrown that judgement away. +// +// One row per event, not one per write. The facts table is append-only, so a +// standup moved from 14:00 to 16:00 leaves two rows under the same key, and the +// day plan used to recite both as if the owner had two meetings. Voided rows +// are excluded, the latest row wins within a source, and the best-evidenced +// source wins across them — a calendar read beats the notification relay that +// guessed at the same meeting. func (s *Store) CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error) { prefixFrom := calendar.KeyPrefixForDay(from) prefixTo := calendar.KeyPrefixForDay(to) @@ -143,7 +150,8 @@ func (s *Store) CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, FROM facts WHERE source IN (`+placeholders(len(sources))+`) AND key >= ? AND key < ? - ORDER BY key`, args...) + AND id NOT IN (SELECT voids_id FROM facts WHERE voids_id IS NOT NULL) + ORDER BY key, ts, id`, args...) if err != nil { return nil, fmt.Errorf("calendar events: %w", err) } @@ -156,7 +164,46 @@ func (s *Store) CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, } out = append(out, f) } - return out, rows.Err() + if err := rows.Err(); err != nil { + return nil, err + } + return latestPerCalendarKey(out), nil +} + +// latestPerCalendarKey reduces the append-only rows for one day to one row per +// event key. Input must be ordered by key then oldest-first, so the last row +// seen for a key and source is that source's current value. +func latestPerCalendarKey(in []Fact) []Fact { + type slot struct { + bySource map[string]Fact + order []string + } + var keys []string + byKey := map[string]*slot{} + for _, f := range in { + s, ok := byKey[f.Key] + if !ok { + s = &slot{bySource: map[string]Fact{}} + byKey[f.Key] = s + keys = append(keys, f.Key) + } + if _, seen := s.bySource[f.Source]; !seen { + s.order = append(s.order, f.Source) + } + s.bySource[f.Source] = f + } + out := make([]Fact, 0, len(keys)) + for _, k := range keys { + s := byKey[k] + best := s.bySource[s.order[0]] + for _, src := range s.order[1:] { + if s.bySource[src].Confidence > best.Confidence { + best = s.bySource[src] + } + } + out = append(out, best) + } + return out } // LatestFactBySource — provenance-scoped. A rule on `service_down` trusts only diff --git a/internal/store/reminders.go b/internal/store/reminders.go index 2fb4447..e3788c5 100644 --- a/internal/store/reminders.go +++ b/internal/store/reminders.go @@ -26,6 +26,15 @@ type Reminder struct { Collapsed []Reminder } +// Reminder lifecycle states. Named for the same reason DigestStatus is: a +// caller filtering on the string literal "pending" is one typo away from a +// filter that silently matches nothing. +const ( + ReminderPending = "pending" + ReminderFired = "fired" + ReminderCancelled = "cancelled" +) + var ( ErrReminderNotFound = errors.New("store: reminder not found") ErrReminderState = errors.New("store: reminder not in a mutable state") @@ -93,6 +102,36 @@ func (s *Store) DueReminders(ctx context.Context, now time.Time) ([]Reminder, er return out, rows.Err() } +// PendingReminders returns the pending reminders whose next fire time falls in +// [from, to), earliest first. +// +// The day plan used to take the newest 500 rows out of ListReminders, which +// orders by creation, and then filter them by day. A reminder stated long ago +// for today fell off the end of that scan while a reminder stated this morning +// for next year stayed on it. Bounding by fire time drops what is out of range +// instead of what is old. +func (s *Store) PendingReminders(ctx context.Context, from, to time.Time) ([]Reminder, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT id, created_ts, fire_ts, next_fire_ts, payload, status, cron + FROM reminders + WHERE status = ? AND next_fire_ts >= ? AND next_fire_ts < ? + ORDER BY next_fire_ts ASC, id ASC`, + ReminderPending, from.UnixMilli(), to.UnixMilli()) + if err != nil { + return nil, fmt.Errorf("pending reminders: %w", err) + } + defer rows.Close() + var out []Reminder + for rows.Next() { + r, err := scanReminder(rows) + if err != nil { + return nil, err + } + out = append(out, r) + } + return out, rows.Err() +} + // MarkReminder sets a reminder's status. Only valid transitions: pending→fired, // pending→cancelled. Anything else is a programming error. func (s *Store) MarkReminder(ctx context.Context, id int64, status string) error { diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 600e526..cdb684b 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -381,6 +381,66 @@ func TestCalendarEvents(t *testing.T) { } } +// A rescheduled meeting keeps its key and appends a row. The query must return +// the current value, not the history: reciting both told the owner he had two +// standups when one had been moved. +func TestCalendarEventsReturnsOneRowPerEvent(t *testing.T) { + store := newTestStore(t) + defer store.Close() + + ctx := context.Background() + day := time.Date(2026, 8, 3, 0, 0, 0, 0, time.UTC) + const key = "calendar_event_20260803_Standup" + + store.WriteFact(ctx, day.Add(14*time.Hour), KindEnv, key, + `"Standup @ 14:00-14:30"`, calendar.SourcePersonal, 1.0, sql.NullInt64{}) + store.WriteFact(ctx, day.Add(16*time.Hour), KindEnv, key, + `"Standup @ 16:00-16:30"`, calendar.SourcePersonal, 1.0, sql.NullInt64{}) + // The notification relay guessed at the same meeting. A calendar read is + // better evidence, so the hedged row must not displace it. + store.WriteFact(ctx, day.Add(17*time.Hour), KindEnv, key, + `"Standup @ 17:00-17:30"`, calendar.SourceAmbient, calendar.AmbientConfidence, sql.NullInt64{}) + + events, err := store.CalendarEvents(ctx, day, day.AddDate(0, 0, 1)) + if err != nil { + t.Fatalf("CalendarEvents: %v", err) + } + if len(events) != 1 { + t.Fatalf("got %d rows, want the current one only: %+v", len(events), events) + } + if events[0].Value != `"Standup @ 16:00-16:30"` { + t.Errorf("value = %q, want the latest calendar read", events[0].Value) + } +} + +// A voided calendar fact is gone, not history to recite. +func TestCalendarEventsSkipsVoidedRows(t *testing.T) { + store := newTestStore(t) + defer store.Close() + + ctx := context.Background() + day := time.Date(2026, 8, 3, 0, 0, 0, 0, time.UTC) + id, err := store.WriteFact(ctx, day.Add(14*time.Hour), KindEnv, "calendar_event_20260803_Cancelled", + `"Cancelled @ 14:00-14:30"`, calendar.SourcePersonal, 1.0, sql.NullInt64{}) + if err != nil { + t.Fatalf("WriteFact: %v", err) + } + if _, err := store.WriteFact(ctx, day.Add(15*time.Hour), KindEnv, "calendar_event_20260803_Cancelled", + `"Cancelled @ 14:00-14:30"`, calendar.SourcePersonal, 1.0, sql.NullInt64{Int64: id, Valid: true}); err != nil { + t.Fatalf("WriteFact void: %v", err) + } + + events, err := store.CalendarEvents(ctx, day, day.AddDate(0, 0, 1)) + if err != nil { + t.Fatalf("CalendarEvents: %v", err) + } + for _, e := range events { + if e.ID == id { + t.Fatalf("voided row %d came back: %+v", id, e) + } + } +} + // The work calendar arrives as relayed phone notifications, not a CalDAV read // (Vikunja #126). Those events belong in the same day's answer, and their // provenance has to survive the query so the caller can hedge them.