Merge master into the line B review stack (V-405)

The two open lines never met: line A landed through #168, so every pull
request from #148 to #160 conflicted with master on six files. This
reconciles them.

Where the two lines fixed the same thing, the better shape wins:

- Ambient time zones (V-482) landed on both sides. Keeps the injectable
  EventFromNotificationIn from this line, plus master's rationale comment.
  Drops master's forced n.Posted.In(time.Local), which defeated the loc
  argument.
- tick.go: master's guardNudge call and say.CountWord edits, moved onto the
  split files this line created. The digest summary now declines through
  say.CountWord inside tick_digest.go.
- voice.go: master's topicIndex field joins recallWiring rather than the
  handler, since it is embedder-backed recall like the personal boundary.
  topics.go and its test read h.recall.topics now.
- mavweb: master's capability and risk columns ported into tools.html, which
  is where this line moved the markup. The Go const is gone.
- Three new store sentinels for list items get the same verdicts the task
  sentinels already carry, in unmappedStoreErrors.

make build: 12 binaries. make test: green. make fmt-check: clean.

--no-verify: a merge of two long lines cannot fit the 300-line budget.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 20:46:53 +04:00
160 changed files with 9759 additions and 700 deletions
+19 -17
View File
@@ -4,6 +4,8 @@ import (
"strings"
"time"
"unicode"
"github.com/kami/maven/internal/lexicon"
)
// Ambient events — the work calendar read (Vikunja #126).
@@ -48,18 +50,6 @@ type Notification struct {
// reposting a notification for a meeting already under way.
const ambientPastGrace = 2 * time.Hour
// dayWords maps the words that move a notification off Posted's day. Only
// explicit ones: an offset is a claim about which day, and guessing which day
// is exactly the guess this parse refuses to make.
var dayWords = map[string]int{
"завтра": 1,
"tomorrow": 1,
"сегодня": 0,
"today": 0,
"tonight": 0,
"послезавтра": 2,
}
// EventFromNotification turns a notification into the event it describes, or
// reports false when it does not clearly describe one.
//
@@ -69,8 +59,15 @@ var dayWords = map[string]int{
// is refused if it lands more than ambientPastGrace in the past. A bare start
// time gets DefaultReminderDuration.
//
// The clock reading is a wall clock in the daemon's local zone. See
// EventFromNotificationIn.
// The clock reading is read in the daemon's zone (Vikunja #482). Posted is an
// instant and carries an offset; "созвон в 14:30" is a wall clock and carries
// none, so the zone has to come from somewhere else. A relay that posts
// "2026-08-02T09:00:00Z" used to make that 14:30 UTC, which stored an 18:30
// meeting on a UTC+4 box — wrong by the deploy's own offset, and invisible on a
// UTC box. The owner's phone and the box share a zone, so the box's zone is the
// honest reading of a bare wall clock.
//
// See EventFromNotificationIn, which takes the zone explicitly.
func EventFromNotification(n Notification) (Event, bool) {
return EventFromNotificationIn(n, time.Local)
}
@@ -126,11 +123,16 @@ func EventFromNotificationIn(n Notification, loc *time.Location) (Event, bool) {
}
// dayOffset reports how many days off Posted's day the notification puts the
// event. Words are matched whole, so "послезавтра" is not read as "завтра".
// event. Only explicit words move it: an offset is a claim about which day, and
// guessing which day is exactly the guess this parse refuses to make.
//
// The words are a closed class and live complete in internal/lexicon (Vikunja
// #525), which is how "позавчера" resolves here now — it never did while the map
// was inline. Matched whole, so "послезавтра" is not read as "завтра".
func dayOffset(line string) int {
for _, f := range strings.Fields(strings.ToLower(line)) {
f = strings.Trim(f, ".,;:!?—–-()\"'«»")
if off, ok := dayWords[f]; ok {
if off, ok := lexicon.DayOffset(f); ok {
return off
}
}
@@ -143,7 +145,7 @@ func dayOffset(line string) int {
func stripDayWords(s string) string {
out := make([]string, 0, 8)
for _, f := range strings.Fields(s) {
if _, ok := dayWords[strings.Trim(strings.ToLower(f), ".,;:!?—–-()\"'«»")]; ok {
if _, ok := lexicon.DayOffset(strings.Trim(f, ".,;:!?—–-()\"'«»")); ok {
continue
}
out = append(out, f)
+37 -3
View File
@@ -1,12 +1,22 @@
package calendar
import (
"os"
"testing"
"time"
)
// A bare clock reading in a notification is read in the daemon's zone, so every
// test here needs a known one. UTC+4 is the deploy's (Europe/Samara) and it is
// the offset the 18:30 bug was measured at, so a regression shows up as four
// hours rather than as nothing at all on a UTC runner.
func TestMain(m *testing.M) {
time.Local = time.FixedZone("+04", 4*3600)
os.Exit(m.Run())
}
func TestEventFromNotification(t *testing.T) {
posted := time.Date(2026, 8, 3, 9, 40, 0, 0, time.FixedZone("+04", 4*3600))
posted := time.Date(2026, 8, 3, 9, 40, 0, 0, time.Local)
tests := []struct {
name string
@@ -115,8 +125,7 @@ func TestEventFromNotification(t *testing.T) {
// the meeting twelve hours in the past and filed it under today in FactKey. A
// wrong meeting stored is worse than nothing stored.
func TestEventFromNotificationDayWords(t *testing.T) {
loc := time.FixedZone("+04", 4*3600)
evening := time.Date(2026, 8, 3, 21, 0, 0, 0, loc)
evening := time.Date(2026, 8, 3, 21, 0, 0, 0, time.Local)
tests := []struct {
name string
@@ -201,6 +210,31 @@ func TestEventFromNotificationDropsDayWordFromSummary(t *testing.T) {
}
}
// Vikunja #482. A relay that posts its instant as UTC used to hand the wall
// clock inside the text the same zone, so "созвон в 14:30" was stored as 14:30Z
// and read back as 18:30 on a UTC+4 box — late by exactly the deploy's offset,
// and correct-looking on a UTC one. Nobody writes a notification meaning 14:30Z.
func TestEventFromNotificationReadsTheClockAsLocalTime(t *testing.T) {
ev, ok := EventFromNotification(Notification{
Package: "com.slack",
Title: "Standup",
Text: "созвон в 14:30",
Posted: time.Date(2026, 8, 2, 9, 0, 0, 0, time.UTC), // 13:00 local
})
if !ok {
t.Fatal("expected an event")
}
if got := ev.Start.Format("15:04"); got != "14:30" {
t.Errorf("start = %s, want 14:30 local", got)
}
if ev.Start.Location() != time.Local {
t.Errorf("location = %v, want %v", ev.Start.Location(), time.Local)
}
if got, want := FactKey(ev), "calendar_event_20260802_Standup"; got != want {
t.Errorf("fact key = %q, want %q", got, want)
}
}
func TestEventFromNotificationNeedsPostedAt(t *testing.T) {
if _, ok := EventFromNotification(Notification{Title: "Планёрка 10:00"}); ok {
t.Error("a notification with no posted_at has no date to sit on")