Merge branch 'fix/g04' into fix/integrated

# Conflicts:
#	cmd/mavend/actions_query.go
#	cmd/mavend/dayplan_test.go
This commit is contained in:
kami
2026-08-01 14:19:15 +04:00
20 changed files with 857 additions and 104 deletions
+47 -6
View File
@@ -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
+74 -11
View File
@@ -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
View File
@@ -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
+24
View File
@@ -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
}
+23
View File
@@ -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
+22 -7
View File
@@ -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))
+50 -2
View File
@@ -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 -4
View File
@@ -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 {
+35
View File
@@ -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
View File
@@ -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
+39
View File
@@ -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 {
+60
View File
@@ -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.