Merge branch 'fix/g04' into fix/integrated
# Conflicts: # cmd/mavend/actions_query.go # cmd/mavend/dayplan_test.go
This commit is contained in:
+25
-13
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
+62
-19
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+49
-2
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user