maven: calendar event querying (task 3)

Store: CalendarEvents(ctx, from, to) — filters caldav facts by key date prefix.
IPC: full wiring through interface, server dispatch, and client.
Router: ParseCalendarDate (сегодня/завтра), CalendarEventFormatter (RU reply).
Voice: calendar detection before notes RAG in IntentQuery handler.
Tests: store integration test, date parser tests, formatter tests.

Co-Authored-By: opencode <opencode@anthropic.com>
This commit is contained in:
kami
2026-07-06 04:09:47 +04:00
parent 3b8fb691cb
commit cf066bde97
11 changed files with 212 additions and 1 deletions
+19
View File
@@ -0,0 +1,19 @@
package router
import (
"fmt"
"strings"
"time"
)
// CalendarEventFormatter formats calendar events into a Russian reply string.
type CalendarEventFormatter struct{}
// Format returns a Russian reply for the given calendar events on the given date.
func (CalendarEventFormatter) Format(events []string, date time.Time) string {
dateStr := date.Format("02.01.2006")
if len(events) == 0 {
return fmt.Sprintf("на %s ничего нет.", dateStr)
}
return fmt.Sprintf("на %s: %s", dateStr, strings.Join(events, "; "))
}
+26
View File
@@ -0,0 +1,26 @@
package router
import (
"testing"
"time"
)
func TestCalendarEventFormatter(t *testing.T) {
f := CalendarEventFormatter{}
date := time.Date(2026, 7, 6, 0, 0, 0, 0, time.UTC)
// Empty
if got := f.Format(nil, date); got != "на 06.07.2026 ничего нет." {
t.Errorf("empty: got %q", got)
}
// One event
if got := f.Format([]string{"Standup @ 10:00-10:30"}, date); got != "на 06.07.2026: Standup @ 10:00-10:30" {
t.Errorf("one: got %q", got)
}
// Multiple events
if got := f.Format([]string{"Standup @ 10:00-10:30", "Lunch @ 12:00-13:00"}, date); got != "на 06.07.2026: Standup @ 10:00-10:30; Lunch @ 12:00-13:00" {
t.Errorf("multiple: got %q", got)
}
}
+14
View File
@@ -327,3 +327,17 @@ func parseDurationValue(s string) (string, bool) {
}
return strconv.Itoa(n) + unit, true
}
// ParseCalendarDate detects RU calendar date words in text and returns the
// resolved time (midnight UTC+0 for "сегодня"/"today", next day for "завтра"/"tomorrow").
// Returns zero time + false if no match.
func ParseCalendarDate(text string, now time.Time) (time.Time, bool) {
lower := strings.ToLower(text)
if strings.Contains(lower, "сегодня") || strings.Contains(lower, "today") {
return now.Truncate(24 * time.Hour), true
}
if strings.Contains(lower, "завтра") || strings.Contains(lower, "tomorrow") {
return now.Truncate(24 * time.Hour).Add(24 * time.Hour), true
}
return time.Time{}, false
}
+29 -1
View File
@@ -1,6 +1,9 @@
package router
import "testing"
import (
"testing"
"time"
)
// Russian capture must produce the same keys the loop's care rules read, or a
// ru voice test silently writes nothing and the nudges look broken.
@@ -28,3 +31,28 @@ func TestDefaultFactParserRU(t *testing.T) {
}
}
}
func TestParseCalendarDate(t *testing.T) {
now := time.Date(2026, 7, 6, 14, 30, 0, 0, time.UTC)
cases := []struct {
text string
want time.Time
ok bool
}{
{"что у меня сегодня по плану", time.Date(2026, 7, 6, 0, 0, 0, 0, time.UTC), true},
{"что завтра", time.Date(2026, 7, 7, 0, 0, 0, 0, time.UTC), true},
{"планы на сегодня", time.Date(2026, 7, 6, 0, 0, 0, 0, time.UTC), true},
{"расписание на завтра", time.Date(2026, 7, 7, 0, 0, 0, 0, time.UTC), true},
{"what's today", time.Date(2026, 7, 6, 0, 0, 0, 0, time.UTC), true},
{"tomorrow plans", time.Date(2026, 7, 7, 0, 0, 0, 0, time.UTC), true},
{"какая погода", time.Time{}, false},
{"сколько времени", time.Time{}, false},
{"", time.Time{}, false},
}
for _, c := range cases {
got, ok := ParseCalendarDate(c.text, now)
if ok != c.ok || !got.Equal(c.want) {
t.Errorf("ParseCalendarDate(%q) = %v, %v; want %v, %v", c.text, got, ok, c.want, c.ok)
}
}
}