diff --git a/cmd/mavend/actions_reminder.go b/cmd/mavend/actions_reminder.go
index ce2a632..1d5410e 100644
--- a/cmd/mavend/actions_reminder.go
+++ b/cmd/mavend/actions_reminder.go
@@ -24,7 +24,10 @@ func (h *reactiveHandler) actionReminder(ctx context.Context, dec router.Decisio
return "не получилось разобрать время напоминания."
}
}
- payload := `{"text":` + jsonString(dec.Utterance) + `}`
+ // The body is what she says at the hour, so the marker and the time come
+ // out of it: the fire time is already a column, and "напомни" is an
+ // instruction that has been carried out (Vikunja #469).
+ payload := `{"text":` + jsonString(reminderBody(dec.Utterance, dec.Slots.Text)) + `}`
if _, err := h.api.CreateReminder(ctx, dec.Slots.Time, payload, ""); err != nil {
log.Printf("voice: create reminder: %v", err)
return "не получилось поставить напоминание."
diff --git a/cmd/mavend/clarify_test.go b/cmd/mavend/clarify_test.go
index db1f140..6708d5b 100644
--- a/cmd/mavend/clarify_test.go
+++ b/cmd/mavend/clarify_test.go
@@ -497,8 +497,12 @@ func TestClarifySubjectAnswerFillsRatherThanClobbers(t *testing.T) {
if !strings.Contains(reminders[0].Payload, "маме") {
t.Fatalf("the answer never reached the reminder: %q", reminders[0].Payload)
}
- if !strings.Contains(reminders[0].Payload, "11") {
- t.Fatalf("the answer clobbered the original request: %q", reminders[0].Payload)
+ // The hour is the fire time, not a word in the body: the body is what she
+ // says at the hour, and the time expression is stripped out of it
+ // (Vikunja #469). Clobbering the parked request would show up here as a
+ // reminder that fires at some other time than the one he asked for.
+ if got := reminders[0].FireTs.UTC(); !got.Equal(at.UTC()) {
+ t.Fatalf("the answer clobbered the original request: fires at %v, want %v", got, at.UTC())
}
}
diff --git a/cmd/mavend/reminderbody.go b/cmd/mavend/reminderbody.go
new file mode 100644
index 0000000..19ea305
--- /dev/null
+++ b/cmd/mavend/reminderbody.go
@@ -0,0 +1,53 @@
+package main
+
+import (
+ "regexp"
+ "strings"
+)
+
+// reminderMarker — the words that open a reminder. Stripped because they are
+// the instruction, not the thing to say at the hour.
+var reminderMarker = regexp.MustCompile(`(?i)^\s*(?:напомни(?:те)?|напомнить|remind)\s*(?:мне|me)?[\s,:—-]*`)
+
+// reminderTimeWords — the time expressions a reminder carries, removed from
+// the body because the fire time is already a column. Ordered longest-first
+// where two could match the same words, so "через полтора часа" does not leave
+// "полтора" behind.
+//
+// Go's \b is ASCII-only and never fires next to a Cyrillic letter, so the word
+// boundaries here are written out as whitespace or an end of string — the same
+// trap the agenda grammars hit.
+var reminderTimeWords = []*regexp.Regexp{
+ regexp.MustCompile(`(?i)(^|\s)через\s+\S+(\s+(часа?|часов|минут[уы]?|секунд[уы]?|дня|дней|недел[юи]))?(\s|$)`),
+ regexp.MustCompile(`(?i)(^|\s)(в|во)\s+\d{1,2}(:\d{2})?(\s*(часа?|часов))?(\s*(утра|вечера|дня|ночи))?(\s|$)`),
+ regexp.MustCompile(`(?i)(^|\s)(завтра|послезавтра|сегодня|вечером|утром|днём|днем|ночью)(\s|$)`),
+ regexp.MustCompile(`(?i)(^|\s)(at|in)\s+\d{1,2}(:\d{2})?\s*(am|pm)?(\s|$)`),
+ regexp.MustCompile(`(?i)(^|\s)(tomorrow|today|tonight)(\s|$)`),
+}
+
+// reminderBody is what she says at the hour.
+//
+// The whole utterance used to be stored, so /reminders read "напомни завтра в
+// 9 утра выпить таблетки" where it should read "выпить таблетки", and the
+// agenda recited the marker back at him (Vikunja #469). The fire time is
+// already a column, and the marker is an instruction that was carried out.
+//
+// Falls back to the fuller text whenever stripping would leave nothing: an
+// empty body is a reminder that fires and says nothing, which is worse than a
+// wordy one.
+func reminderBody(utterance, text string) string {
+ body := strings.TrimSpace(text)
+ if body == "" {
+ body = strings.TrimSpace(utterance)
+ }
+ stripped := reminderMarker.ReplaceAllString(body, "")
+ for _, re := range reminderTimeWords {
+ stripped = re.ReplaceAllString(stripped, " ")
+ }
+ stripped = strings.TrimSpace(strings.Join(strings.Fields(stripped), " "))
+ stripped = strings.Trim(stripped, " ,;:—-")
+ if stripped == "" {
+ return body
+ }
+ return stripped
+}
diff --git a/cmd/mavend/reminderbody_test.go b/cmd/mavend/reminderbody_test.go
new file mode 100644
index 0000000..3597237
--- /dev/null
+++ b/cmd/mavend/reminderbody_test.go
@@ -0,0 +1,21 @@
+package main
+
+import "testing"
+
+func TestReminderBody(t *testing.T) {
+ for _, tc := range []struct{ utterance, text, want string }{
+ // The row from the QA sitting: the whole utterance was the body.
+ {"напомни завтра в 9 утра выпить таблетки", "завтра в 9 утра выпить таблетки", "выпить таблетки"},
+ {"напомни мне позвонить маме в семь вечера", "позвонить маме в 7 вечера", "позвонить маме"},
+ {"напомни через полчаса проверить бэкап", "через полчаса проверить бэкап", "проверить бэкап"},
+ {"remind me to call mom at 7pm", "to call mom at 7pm", "to call mom"},
+ // Nothing left after stripping ⇒ keep what there was. A reminder that
+ // fires and says nothing is worse than a wordy one.
+ {"напомни завтра", "завтра", "завтра"},
+ {"", "", ""},
+ } {
+ if got := reminderBody(tc.utterance, tc.text); got != tc.want {
+ t.Errorf("reminderBody(%q, %q) = %q, want %q", tc.utterance, tc.text, got, tc.want)
+ }
+ }
+}
diff --git a/cmd/mavweb/main.go b/cmd/mavweb/main.go
index 9d8368c..14aca14 100644
--- a/cmd/mavweb/main.go
+++ b/cmd/mavweb/main.go
@@ -946,6 +946,51 @@ func deliveryRows(as []ipc.DeliveryAttempt) []deliveryRow {
return out
}
+// reminderRow is one line on /reminders, with the payload unwrapped and both
+// timestamps already in his clock.
+//
+// The page rendered `{{.Payload}}` and the UTC instant, so a reminder read
+// `{"text":"выпить таблетки"}` and fired an hour off what he was told
+// (Vikunja #469). Neither is a formatting nicety: the envelope is an internal
+// shape he never chose, and a time on a page he reads is the time on his wall.
+type reminderRow struct {
+ Created string
+ Fires string
+ Status string
+ Text string
+}
+
+// reminderText unwraps the {"text":...} payload the router writes.
+//
+// A copy of store.ReminderText rather than a call to it, because mavweb is one
+// of the pure-Go daemons and internal/store carries the CGO sqlite driver. The
+// ipc DTO is decoupled from the store on purpose, so the unwrap belongs to
+// whoever renders it. Payload that is not that shape is shown as he said it.
+func reminderText(payload string) string {
+ var m map[string]any
+ if err := json.Unmarshal([]byte(payload), &m); err == nil {
+ if t, ok := m["text"]; ok {
+ if s, isStr := t.(string); isStr && s != "" {
+ return s
+ }
+ }
+ }
+ return strings.TrimSpace(payload)
+}
+
+func reminderRows(rs []ipc.Reminder) []reminderRow {
+ out := make([]reminderRow, 0, len(rs))
+ for _, r := range rs {
+ out = append(out, reminderRow{
+ Created: r.CreatedTs.Local().Format("02 Jan 15:04"),
+ Fires: r.FireTs.Local().Format("02 Jan 15:04"),
+ Status: r.Status,
+ Text: reminderText(r.Payload),
+ })
+ }
+ return out
+}
+
func handleReminders(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
if core == nil {
http.Error(w, "reminders disabled (no -core)", http.StatusServiceUnavailable)
@@ -959,7 +1004,7 @@ func handleReminders(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
- if err := remindersTmpl.Execute(w, map[string]any{"Reminders": reminders}); err != nil {
+ if err := remindersTmpl.Execute(w, map[string]any{"Reminders": reminderRows(reminders)}); err != nil {
log.Printf("reminders template: %v", err)
}
}
diff --git a/cmd/mavweb/reminders.html b/cmd/mavweb/reminders.html
index e0f6bb6..e28ef22 100644
--- a/cmd/mavweb/reminders.html
+++ b/cmd/mavweb/reminders.html
@@ -3,10 +3,10 @@
{{if .Reminders}}
created
fires
status
what
{{range .Reminders}}
-
{{.CreatedTs.Format "02 Jan 15:04"}}
-
{{.FireTs.Format "02 Jan 15:04"}}
+
{{.Created}}
+
{{.Fires}}
{{.Status}}
-
{{.Payload}}
+
{{.Text}}
{{end}}
{{else}}
diff --git a/cmd/mavweb/reminders_test.go b/cmd/mavweb/reminders_test.go
new file mode 100644
index 0000000..0fa4384
--- /dev/null
+++ b/cmd/mavweb/reminders_test.go
@@ -0,0 +1,46 @@
+package main
+
+import (
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/kami/maven/internal/ipc"
+)
+
+// The page showed the storage envelope and the UTC instant (Vikunja #469).
+func TestReminderRowsUnwrapAndLocalise(t *testing.T) {
+ fire := time.Date(2026, 8, 4, 18, 30, 0, 0, time.UTC)
+ rows := reminderRows([]ipc.Reminder{{
+ CreatedTs: fire.Add(-time.Hour),
+ FireTs: fire,
+ Status: "pending",
+ Payload: `{"text":"выпить таблетки"}`,
+ }})
+ if len(rows) != 1 {
+ t.Fatalf("rows = %d, want 1", len(rows))
+ }
+ if rows[0].Text != "выпить таблетки" {
+ t.Errorf("Text = %q, want the words without the envelope", rows[0].Text)
+ }
+ if want := fire.Local().Format("02 Jan 15:04"); rows[0].Fires != want {
+ t.Errorf("Fires = %q, want %q", rows[0].Fires, want)
+ }
+ if strings.Contains(rows[0].Text, "{") {
+ t.Errorf("Text still carries JSON: %q", rows[0].Text)
+ }
+}
+
+// A payload that is not the envelope is his own words, so it is shown as it is.
+func TestReminderTextKeepsPlainPayload(t *testing.T) {
+ for _, tc := range []struct{ in, want string }{
+ {`{"text":"позвонить маме"}`, "позвонить маме"},
+ {" полить цветы ", "полить цветы"},
+ {`{"body":"nope"}`, `{"body":"nope"}`},
+ {"", ""},
+ } {
+ if got := reminderText(tc.in); got != tc.want {
+ t.Errorf("reminderText(%q) = %q, want %q", tc.in, got, tc.want)
+ }
+ }
+}
diff --git a/internal/router/dateparser.go b/internal/router/dateparser.go
index c51db10..ad94848 100644
--- a/internal/router/dateparser.go
+++ b/internal/router/dateparser.go
@@ -81,6 +81,9 @@ func NewPythonDateParser() *PythonDateParser {
// or dateparser is unavailable, falls back to the stub parser. Returns
// (time, true, nil) on success; (zero, false, nil) when no date is found.
func (p *PythonDateParser) Parse(ctx context.Context, text string, now time.Time) (time.Time, bool, error) {
+ // Speech says the hour in words, and neither this parser nor the stub
+ // reads "в семь вечера" (Vikunja #469). Both see the digits instead.
+ text = SpellOutDigits(text)
t, ok, err := p.parseWithPython(ctx, text, now)
if err != nil {
// python3 missing, dateparser not installed, or process failure —
diff --git a/internal/router/numwords.go b/internal/router/numwords.go
new file mode 100644
index 0000000..dbce260
--- /dev/null
+++ b/internal/router/numwords.go
@@ -0,0 +1,97 @@
+package router
+
+import "strings"
+
+// ruNumerals — spoken numbers as digits, for the clock hours and the minutes
+// that follow them. Every case ending he might say is listed rather than
+// stemmed: "в семь", "к семи", "около семи" are three forms of one hour, and a
+// prefix rule short enough to cover them also matches "семья".
+//
+// Stops at thirty, which is as far as a spoken time goes ("без двадцати
+// восемь", "в половине шестого"). Anything larger is said in digits.
+var ruNumerals = map[string]string{
+ "один": "1", "одного": "1", "одну": "1", "час": "1", "часу": "1",
+ "два": "2", "две": "2", "двух": "2",
+ "три": "3", "трёх": "3", "трех": "3",
+ "четыре": "4", "четырёх": "4", "четырех": "4",
+ "пять": "5", "пяти": "5",
+ "шесть": "6", "шести": "6",
+ "семь": "7", "семи": "7",
+ "восемь": "8", "восьми": "8",
+ "девять": "9", "девяти": "9",
+ "десять": "10", "десяти": "10",
+ "одиннадцать": "11", "одиннадцати": "11",
+ "двенадцать": "12", "двенадцати": "12",
+ "тринадцать": "13", "тринадцати": "13",
+ "четырнадцать": "14", "четырнадцати": "14",
+ "пятнадцать": "15", "пятнадцати": "15",
+ "шестнадцать": "16", "шестнадцати": "16",
+ "семнадцать": "17", "семнадцати": "17",
+ "восемнадцать": "18", "восемнадцати": "18",
+ "девятнадцать": "19", "девятнадцати": "19",
+ "двадцать": "20", "двадцати": "20",
+ "тридцать": "30", "тридцати": "30",
+ "сорок": "40", "сорока": "40",
+ "пятьдесят": "50", "пятидесяти": "50",
+}
+
+// numeralContext — the words that make a numeral a time. A numeral is only
+// rewritten when one of these sits next to it, so "три яблока" in a note is
+// left alone and "в три часа" is not.
+var numeralContext = map[string]bool{
+ "в": true, "во": true, "к": true, "около": true, "на": true,
+ "часа": true, "часов": true, "час": true, "часу": true,
+ "утра": true, "вечера": true, "дня": true, "ночи": true,
+ "минут": true, "минуты": true, "минуту": true,
+ "at": true, "by": true,
+}
+
+// SpellOutDigits rewrites spoken numbers as digits so the date parsers see the
+// shape they know.
+//
+// "напомни мне позвонить маме в семь вечера" parsed to nothing, while "в 19:00"
+// parsed fine (Vikunja #469). Speech is where reminders come from, and speech
+// says the hour in words, so this is not a long-tail case — it is the ordinary
+// one. dateparser reads "в 7 вечера" through the qualifier rewrite the python
+// script already does; it does not read "в семь вечера".
+//
+// Conservative by construction: a numeral is only rewritten when a time word
+// stands beside it. "три часа" becomes "3 часа"; "три яблока" stays as it is,
+// and a note or a fact carrying a spoken number is untouched.
+func SpellOutDigits(text string) string {
+ toks := strings.Fields(text)
+ if len(toks) == 0 {
+ return text
+ }
+ out := make([]string, len(toks))
+ copy(out, toks)
+ for i, tok := range toks {
+ key := strings.ToLower(strings.Trim(tok, ".,!?;:«»\"'"))
+ digit, ok := ruNumerals[key]
+ if !ok {
+ continue
+ }
+ // "час" and "часу" are the hour noun as often as they are the number
+ // one, and rewriting "в час дня" to "в 1 дня" is right either way. What
+ // must not happen is rewriting the noun that gives another numeral its
+ // context: "в семь часов" must keep "часов".
+ if !hasTimeNeighbour(toks, i) {
+ continue
+ }
+ out[i] = digit
+ }
+ return strings.Join(out, " ")
+}
+
+// hasTimeNeighbour reports whether the token before or after i is a time word.
+func hasTimeNeighbour(toks []string, i int) bool {
+ for _, j := range []int{i - 1, i + 1} {
+ if j < 0 || j >= len(toks) {
+ continue
+ }
+ if numeralContext[strings.ToLower(strings.Trim(toks[j], ".,!?;:«»\"'"))] {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/router/numwords_test.go b/internal/router/numwords_test.go
new file mode 100644
index 0000000..f16d854
--- /dev/null
+++ b/internal/router/numwords_test.go
@@ -0,0 +1,38 @@
+package router
+
+import (
+ "context"
+ "testing"
+ "time"
+)
+
+func TestSpellOutDigits(t *testing.T) {
+ for _, tc := range []struct{ in, want string }{
+ {"напомни мне позвонить маме в семь вечера", "напомни мне позвонить маме в 7 вечера"},
+ {"в три часа дня", "в 3 часа дня"},
+ {"напомни в половине шестого", "напомни в половине шестого"},
+ {"через двадцать минут", "через 20 минут"},
+ // Untouched: no time word stands beside the number.
+ {"купить три яблока", "купить три яблока"},
+ {"семь раз отмерь", "семь раз отмерь"},
+ {"напомни в 19:00", "напомни в 19:00"},
+ {"", ""},
+ } {
+ if got := SpellOutDigits(tc.in); got != tc.want {
+ t.Errorf("SpellOutDigits(%q) = %q, want %q", tc.in, got, tc.want)
+ }
+ }
+}
+
+// The utterance from the QA sitting that named this bug: the numeric form
+// parsed and the spoken form did not.
+func TestStubParsesASpokenHour(t *testing.T) {
+ now := time.Date(2026, 8, 2, 9, 0, 0, 0, time.Local)
+ got, ok, err := StubDateTimeParser{}.Parse(context.Background(), "напомни мне позвонить маме в семь вечера", now)
+ if err != nil || !ok {
+ t.Fatalf("Parse ok=%v err=%v, want a time", ok, err)
+ }
+ if got.Hour() != 19 {
+ t.Fatalf("hour = %d, want 19", got.Hour())
+ }
+}
diff --git a/internal/router/slots.go b/internal/router/slots.go
index 95998a2..6a9e701 100644
--- a/internal/router/slots.go
+++ b/internal/router/slots.go
@@ -176,7 +176,7 @@ func afterWord(s, w string) string {
type StubDateTimeParser struct{}
func (StubDateTimeParser) Parse(_ context.Context, text string, now time.Time) (time.Time, bool, error) {
- s := strings.ToLower(strings.TrimSpace(text))
+ s := strings.ToLower(strings.TrimSpace(SpellOutDigits(text)))
toks := strings.Fields(s)
// scan for "in " anywhere — dateparser extracts the datetime
// expression from surrounding text; the stub does the same naively.
@@ -204,14 +204,22 @@ func (StubDateTimeParser) Parse(_ context.Context, text string, now time.Time) (
// --- Russian time expressions (stub floor; dateparser replaces) ---
- // "в " anywhere — mirror of the English "at" scan.
+ // "в " anywhere — mirror of the English "at" scan. A qualifier
+ // after the hour moves it into the afternoon: "в 7 вечера" is 19:00, and
+ // with SpellOutDigits in front of this that is what "в семь вечера" reads
+ // as too (Vikunja #469).
for i := 0; i+1 < len(toks); i++ {
if toks[i] != "в" {
continue
}
- if t, ok := parseClock(toks[i+1], now); ok {
- return t, true, nil
+ t, ok := parseClock(toks[i+1], now)
+ if !ok {
+ continue
}
+ if i+2 < len(toks) {
+ t = applyRuQualifier(t, toks[i+2], now)
+ }
+ return t, true, nil
}
// "через " / "через " (bare = 1) / "через полчаса".
@@ -476,3 +484,30 @@ func midnight(now time.Time, days int) time.Time {
y, m, d := now.AddDate(0, 0, days).Date()
return time.Date(y, m, d, 0, 0, 0, 0, now.Location())
}
+
+// applyRuQualifier moves an hour into the afternoon when he said "вечера" or
+// "дня" after it. Noon-crossing only: 7 becomes 19, and 19 stays 19. Morning
+// qualifiers need no arithmetic, they only confirm the hour as spoken.
+//
+// The date is recomputed rather than shifted, so an hour that parseClock
+// already pushed to tomorrow does not land two days out.
+func applyRuQualifier(t time.Time, qualifier string, now time.Time) time.Time {
+ h := t.Hour()
+ switch strings.Trim(strings.ToLower(qualifier), ".,!?;:") {
+ case "вечера", "дня":
+ if h < 12 {
+ h += 12
+ }
+ case "утра", "ночи":
+ if h == 12 {
+ h = 0
+ }
+ default:
+ return t
+ }
+ out := time.Date(now.Year(), now.Month(), now.Day(), h, t.Minute(), 0, 0, now.Location())
+ if !out.After(now) {
+ out = out.Add(24 * time.Hour)
+ }
+ return out
+}