Compare commits

...

2 Commits

Author SHA1 Message Date
kami da647e87d0 Read spending from zenmoney in the poller, answer it from facts (#125)
The trust boundary is zenmoney, not maven — they already hold his bank
sessions. So the poller reads /v8/diff/ and writes totals as
facts(kind=env, source=poll:zenmoney); core reads those back when he asks
and never sees the token.

internal/zenmoney sums transactions per currency over a window, skipping
tombstoned rows and transfers between his own accounts, and refuses to
encode a summary built from zero transactions. That refusal is the whole
design: a failed or empty read writes nothing and leaves the last good
total alone, because a zero recited as fact is worse than silence. No
currency conversion either — a figure he can check against his bank beats
one he cannot.

Off unless configured, and the token is read from a FILE rather than a
flag so it never lands in `ps`, in docker-compose.yml, or in shell
history. Nothing about the money is search input, no tick rule reads the
keys, and the log lines name keys, never figures.

The live-credential half is BLOCKED: there is no zenmoney account or token
here, so everything is verified against a recorded diff fixture.
2026-08-01 02:50:27 +04:00
kami bf6ccf9aea Rank captured tasks by what he actually said (#129)
Ordering is computed, not generated. Asking a 1.7B which of his tasks
matters most produces a fluent opinion with no basis in anything, and a
confidently wrong priority is worse than none — same posture as the
behaviour profile in internal/memory, which counts instead of summarising.

internal/tasks is a pure package (no ipc, no store, no cgo) holding the
score, the order and the Russian rendering, so the spoken list and the
/tasks page cannot drift. Four signals, all of them things he stated:
deadline (overdue > today > tomorrow > this week), stated urgency, age
with a cap so nothing rots at the bottom, and confirmed work always
ahead of mail-derived candidates. A task with no due date and no weight
scores nothing and carries no reason string — inventing a "потому что"
about a priority he never set is the failure mode this avoids.

Capture now picks up urgency he says out loud ("добавь в задачи срочно
оплатить интернет"), stripping the marker from the task text, and the web
add form offers the same three rungs. Ranking is a read: it sorts and
renders, never writes, schedules or announces.
2026-08-01 02:42:02 +04:00
22 changed files with 1832 additions and 70 deletions
+2
View File
@@ -34,6 +34,8 @@ deps
deploy/db_key.env
# Deploy secret (telegram bot token + chat id) — never commit
deploy/telegram.env
# zenmoney API token, read by mavpoll (never in argv, never committed)
deploy/zenmoney.token
# Temp files
/tmp/
+78
View File
@@ -0,0 +1,78 @@
package main
import (
"context"
"log"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/zenmoney"
)
// Money questions (Vikunja #125).
//
// This is the whole read side: mavpoll holds the zenmoney token and writes
// facts(kind=env, source=poll:zenmoney); core reads them back when he asks.
// Core never sees the token, never calls zenmoney, and has no rule on these
// keys — a total is never a reason for Maven to speak first. Maven is not a
// nag, least of all about his money.
//
// Nothing here can reach the external search capability: the figures are read
// from the store and rendered locally, and his financial data is never search
// input.
// queryMoney — "сколько я потратил сегодня?", "покажи мои траты".
//
// Answers only from the latest fact the poller wrote. Three honest outcomes and
// no fourth: the figure, "the fact is old and here is its date", or "money
// tracking is not connected". It never computes, estimates or rounds a total of
// its own — an invented number about his money is the worst thing this could do.
func (h *reactiveHandler) queryMoney(ctx context.Context, t *queryTurn) (string, bool) {
window, ok := router.ParseMoneyQuery(t.dec.Utterance)
if !ok {
return "", false
}
key, phrase := zenmoney.KeySpentMonth, "в этом месяце"
if window == router.MoneyToday {
key, phrase = zenmoney.KeySpentToday, "сегодня"
}
fact, err := h.api.LatestFactBySource(ctx, key, zenmoney.Source)
if err != nil {
// No fact at all is the normal state when the capability is off. Claim
// the turn anyway: falling through to recall would answer a question
// about money with whatever note happens to be nearest.
if !isNoFactErr(err) {
log.Printf("voice: money fact: %v", err)
}
return "я не отслеживаю траты — не подключено.", true
}
val, err := zenmoney.ParseFactValue(fact.Value)
if err != nil {
log.Printf("voice: money fact: decode: %v", err)
return "не получилось прочитать траты.", true
}
reply := val.FormatRU(phrase)
if reply == "" {
return "по тратам пока нечего сказать.", true
}
// A stale fact is reported as stale rather than spoken as today's number.
if h.now().Sub(fact.Ts) > zenmoney.StaleAfter {
return "данные от " + fact.Ts.Local().Format("02.01") + ": " + reply, true
}
return reply, true
}
// isNoFactErr — ErrNoFact survives the wire wrapped, so unwrap for it.
func isNoFactErr(err error) bool {
for e := err; e != nil; {
if e == ipc.ErrNoFact {
return true
}
u, ok := e.(interface{ Unwrap() error })
if !ok {
return false
}
e = u.Unwrap()
}
return false
}
+139
View File
@@ -0,0 +1,139 @@
package main
import (
"context"
"strings"
"testing"
"time"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/zenmoney"
)
// moneyAPI answers only LatestFactBySource; everything else is unimplemented,
// which is the assertion that answering a money question costs no model call
// and reaches no network.
type moneyAPI struct {
ipc.UnimplementedCoreAPI
fact ipc.Fact
err error
gotKey string
gotSrc string
callCnt int
}
func (a *moneyAPI) LatestFactBySource(_ context.Context, key, source string) (ipc.Fact, error) {
a.gotKey, a.gotSrc = key, source
a.callCnt++
return a.fact, a.err
}
func moneyNow() time.Time { return time.Date(2026, 8, 15, 20, 0, 0, 0, time.UTC) }
func moneyFact(ts time.Time, val string) ipc.Fact {
return ipc.Fact{Kind: "env", Key: zenmoney.KeySpentMonth, Value: val, Source: zenmoney.Source, Ts: ts}
}
func TestQueryMoneyAnswersFromTheFact(t *testing.T) {
api := &moneyAPI{fact: moneyFact(moneyNow(), `{"spent":[{"currency":"RUB","amount":1749.5}],"count":3}`)}
h := &reactiveHandler{api: api, now: moneyNow}
reply, ok := h.queryMoney(context.Background(), &queryTurn{
dec: router.Decision{Utterance: "сколько я потратил в этом месяце?"},
})
if !ok {
t.Fatal("the money source must claim a money question")
}
if api.gotKey != zenmoney.KeySpentMonth || api.gotSrc != zenmoney.Source {
t.Errorf("read %q/%q, want the month key from the poller's source", api.gotKey, api.gotSrc)
}
if !strings.Contains(reply, "1749.5") {
t.Errorf("reply = %q, want the exact figure", reply)
}
if !strings.Contains(reply, "в этом месяце") {
t.Errorf("reply = %q, want the window named", reply)
}
}
func TestQueryMoneyPicksTodaysKey(t *testing.T) {
api := &moneyAPI{fact: moneyFact(moneyNow(), `{"spent":[{"currency":"RUB","amount":250}],"count":1}`)}
h := &reactiveHandler{api: api, now: moneyNow}
if _, ok := h.queryMoney(context.Background(), &queryTurn{
dec: router.Decision{Utterance: "сколько я потратил сегодня?"},
}); !ok {
t.Fatal("expected the source to claim it")
}
if api.gotKey != zenmoney.KeySpentToday {
t.Errorf("key = %q, want today's", api.gotKey)
}
}
// The capability is off unless configured, and then there is no fact. She says
// so instead of letting the recall pass answer a money question from a note.
func TestQueryMoneySaysNotConnected(t *testing.T) {
h := &reactiveHandler{api: &moneyAPI{err: ipc.ErrNoFact}, now: moneyNow}
reply, ok := h.queryMoney(context.Background(), &queryTurn{
dec: router.Decision{Utterance: "сколько я потратил?"},
})
if !ok {
t.Fatal("expected the source to claim it")
}
if !strings.Contains(reply, "не подключено") {
t.Errorf("reply = %q, want an honest 'not connected'", reply)
}
// No number of any kind in that answer.
for _, d := range []string{"0", "1", "2", "3", "4", "5", "6", "7", "8", "9"} {
if strings.Contains(reply, d) {
t.Errorf("reply %q contains a digit — nothing was read, so there is no figure", reply)
}
}
}
// A fact older than the staleness bound is dated rather than spoken as if it
// were current: the poller can be down, and last week's total presented as
// today's is a lie by omission.
func TestQueryMoneyDatesAStaleFact(t *testing.T) {
old := moneyNow().Add(-72 * time.Hour)
api := &moneyAPI{fact: moneyFact(old, `{"spent":[{"currency":"RUB","amount":100}],"count":1}`)}
h := &reactiveHandler{api: api, now: moneyNow}
reply, _ := h.queryMoney(context.Background(), &queryTurn{
dec: router.Decision{Utterance: "сколько я потратил?"},
})
if !strings.Contains(reply, "данные от") {
t.Errorf("reply = %q, want the stale fact dated", reply)
}
}
func TestQueryMoneyPassesOtherQuestions(t *testing.T) {
api := &moneyAPI{}
h := &reactiveHandler{api: api, now: moneyNow}
for _, u := range []string{"какая погода?", "я потратил весь день на это", "какие у меня задачи?"} {
if _, ok := h.queryMoney(context.Background(), &queryTurn{dec: router.Decision{Utterance: u}}); ok {
t.Errorf("the money source claimed %q", u)
}
}
if api.callCnt != 0 {
t.Error("a non-money question must not read the money facts")
}
}
// Money must be answered before the recall sources, or a question about
// spending gets answered by the nearest note.
func TestQuerySourcesOrderMoneyBeforeRecall(t *testing.T) {
moneyAt, notesAt := -1, -1
for i, src := range querySources {
switch src.name {
case "money":
moneyAt = i
case "notes":
notesAt = i
}
}
if moneyAt < 0 || notesAt < 0 {
t.Fatalf("sources missing: money=%d notes=%d", moneyAt, notesAt)
}
if moneyAt > notesAt {
t.Errorf("money source at %d, after notes at %d", moneyAt, notesAt)
}
}
+5
View File
@@ -62,6 +62,11 @@ var querySources = []querySource{
// matcher requires a task noun or an explicit "что … сделать", so a
// date-bearing question still reaches the calendar.
{"tasks", (*reactiveHandler).queryTasks},
// Before the recall sources too: "сколько я потратил?" is a question about
// the money facts the poller wrote, and the notes pass would otherwise
// answer it from whatever he once said about spending. Its matcher needs a
// money noun plus an actual ask, so "я потратил весь день" is untouched.
{"money", (*reactiveHandler).queryMoney},
{"calendar", (*reactiveHandler).queryCalendar},
{"weather", (*reactiveHandler).queryWeather},
{"embed", (*reactiveHandler).queryEmbed},
+23 -39
View File
@@ -3,11 +3,11 @@ package main
import (
"context"
"log"
"strings"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/store"
"github.com/kami/maven/internal/tasks"
)
// Task capture on the voice/chat path (Vikunja #130).
@@ -27,14 +27,15 @@ import (
// captureTaskFromNote claims the turn when the utterance explicitly files a
// task, returning the reply. ("", false) hands the turn back to the note path.
func (h *reactiveHandler) captureTaskFromNote(ctx context.Context, dec router.Decision) (string, bool) {
text, ok := router.ParseTaskCapture(dec.Utterance)
cap, ok := router.ParseTaskCapture(dec.Utterance)
if !ok {
return "", false
}
resp, err := h.api.CaptureTask(ctx, ipc.CaptureTaskReq{
Text: text,
Text: cap.Text,
Source: "tap:voice",
Status: store.TaskOpen, // he stated it himself — not a candidate
Weight: cap.Weight, // 0 unless he said "срочно" / "важно"
Ts: h.now(),
})
if err != nil {
@@ -44,56 +45,39 @@ func (h *reactiveHandler) captureTaskFromNote(ctx context.Context, dec router.De
if !resp.Created {
return "это уже в списке.", true
}
return "записала: " + text, true
return "записала: " + cap.Text, true
}
// queryTasks — "какие у меня задачи?", "что мне нужно сделать?".
//
// Reads the live set and recites it. Newest first, which is the order the store
// returns: this source has no opinion about which task matters more, and
// pretending otherwise would be a guess. Ranking is Vikunja #129.
// Reads the live set and recites it in priority order (Vikunja #129). The order
// is computed by internal/tasks from what he told her — deadlines, the urgency
// he stated, how long a task has been sitting — never asked of the model. The
// rendering is the package's too, so the spoken list and the /tasks page can
// never disagree about what comes first.
func (h *reactiveHandler) queryTasks(ctx context.Context, t *queryTurn) (string, bool) {
if !router.IsTaskListQuery(t.dec.Utterance) {
return "", false
}
tasks, err := h.api.ListTasks(ctx, "live")
live, err := h.api.ListTasks(ctx, "live")
if err != nil {
log.Printf("voice: list tasks: %v", err)
return "не получилось посмотреть задачи.", true
}
return formatTaskListRU(tasks), true
return tasks.FormatRU(tasks.Rank(taskItems(live), h.now())), true
}
// formatTaskListRU renders the live task list the way Maven says it. Candidates
// are named as candidates — a task she pulled out of his mail is something she
// suggests, and saying it in the same breath as work he actually stated would
// put words in his mouth.
func formatTaskListRU(tasks []ipc.Task) string {
var open, cands []string
for _, t := range tasks {
switch t.Status {
case store.TaskCandidate:
cands = append(cands, t.Text)
default:
open = append(open, t.Text)
// taskItems maps wire rows onto the ranker's input. Written here rather than in
// internal/tasks so the ranker stays a pure package with no ipc (and therefore
// no store, and therefore no cgo) dependency — the same posture as
// internal/morning and internal/memory.
func taskItems(ts []ipc.Task) []tasks.Item {
out := make([]tasks.Item, len(ts))
for i, t := range ts {
out[i] = tasks.Item{
ID: t.ID, Text: t.Text, Status: t.Status,
Created: t.CreatedTs, Due: t.Due, Weight: t.Weight,
}
}
if len(open) == 0 && len(cands) == 0 {
return "задач нет."
}
var b strings.Builder
if len(open) > 0 {
b.WriteString("в списке: ")
b.WriteString(strings.Join(open, "; "))
b.WriteString(".")
}
if len(cands) > 0 {
if b.Len() > 0 {
b.WriteString(" ")
}
b.WriteString("ещё я нашла, но ты не подтвердил: ")
b.WriteString(strings.Join(cands, "; "))
b.WriteString(".")
}
return b.String()
return out
}
+39
View File
@@ -141,6 +141,45 @@ func TestQueryTasksRecitesTheLiveList(t *testing.T) {
}
}
// The stated urgency rides through capture as a weight, so the ranker can use
// it later (Vikunja #129). "срочно" is not part of the task text.
func TestCaptureTaskCarriesStatedUrgency(t *testing.T) {
api := &taskAPI{created: true}
h := taskHandler(api)
if _, ok := h.captureTaskFromNote(context.Background(), router.Decision{
Utterance: "добавь в задачи срочно оплатить интернет",
}); !ok {
t.Fatal("expected a capture")
}
got := api.captured[0]
if got.Text != "оплатить интернет" {
t.Errorf("text = %q, want the urgency word out of the task", got.Text)
}
if got.Weight == 0 {
t.Error("weight = 0 — he said срочно and it was dropped")
}
}
// The recital is ordered by the ranker, not by insertion: a deadline he named
// comes before undated work.
func TestQueryTasksRecitesInPriorityOrder(t *testing.T) {
due := taskNow()
api := &taskAPI{tasks: []ipc.Task{
{ID: 1, Text: "купить молоко", Status: "open", CreatedTs: taskNow()},
{ID: 2, Text: "оплатить интернет", Status: "open", CreatedTs: taskNow(), Due: &due},
}}
h := taskHandler(api)
reply, _ := h.queryTasks(context.Background(), &queryTurn{
dec: router.Decision{Utterance: "какие у меня задачи?"},
})
if strings.Index(reply, "оплатить интернет") > strings.Index(reply, "купить молоко") {
t.Errorf("reply = %q, want the dated task first", reply)
}
if !strings.Contains(reply, "сегодня") {
t.Errorf("reply = %q, want the reason named", reply)
}
}
func TestQueryTasksEmptyList(t *testing.T) {
h := taskHandler(&taskAPI{})
reply, ok := h.queryTasks(context.Background(), &queryTurn{
+124 -3
View File
@@ -9,6 +9,12 @@
// Two sources, each its own provenance (the loop's rules trust source):
// - netdata → poll:netdata resource alarms (disk/mem/cert/temp)
// - kuma → poll:uptimekuma service up/down (the source of truth for it)
// - zenmoney → poll:zenmoney spending/income totals (Vikunja #125)
//
// The zenmoney source is why the token lives HERE and not in core: the poller
// already owns every other third-party credential, it holds no store key, and
// core never needs to know an account exists to answer a question about a fact
// the poller wrote. It is off unless -zenmoney-token-file is given.
//
// Netdata needs no auth over the wg-fronted net. Kuma's /metrics needs an API
// key (basic-auth); without -kuma the whole kuma path is skipped (netdata-only
@@ -37,6 +43,7 @@ import (
"time"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/zenmoney"
)
func main() {
@@ -52,6 +59,9 @@ func run(args []string) error {
netdataURL := fs.String("netdata", "http://127.0.0.1:19999", "netdata base URL ('' to disable)")
kumaURL := fs.String("kuma", "", "uptime-kuma metrics URL, e.g. http://127.0.0.1:3001/metrics ('' to disable)")
kumaKey := fs.String("kuma-key", "", "uptime-kuma API key (basic-auth username)")
zenTokenFile := fs.String("zenmoney-token-file", "", "file holding the zenmoney API token ('' disables money tracking)")
zenURL := fs.String("zenmoney-url", zenmoney.DefaultBaseURL, "zenmoney API base URL (tests/self-hosted proxies)")
zenInterval := fs.Duration("zenmoney-interval", time.Hour, "how often to read zenmoney (money does not move every minute)")
wgIface := fs.String("wg", "", "wireguard interface for the presence signal, e.g. wg0 or 'all' ('' to disable)")
wgCmd := fs.String("wg-cmd", "wg", "wg binary (use e.g. 'sudo wg' if the poller lacks CAP_NET_ADMIN)")
interval := fs.Duration("interval", 60*time.Second, "poll cadence")
@@ -62,8 +72,24 @@ func run(args []string) error {
if *socket == "" {
return fmt.Errorf("-socket is required")
}
if *netdataURL == "" && *kumaURL == "" && *wgIface == "" {
return fmt.Errorf("nothing to poll: set -netdata, -kuma and/or -wg")
if *netdataURL == "" && *kumaURL == "" && *wgIface == "" && *zenTokenFile == "" {
return fmt.Errorf("nothing to poll: set -netdata, -kuma, -wg and/or -zenmoney-token-file")
}
// The token is read from a file, never taken as a flag value: an argv token
// is visible in `ps` to every user on the box and lands in the compose file
// and the shell history. Read once at start — a rotated token means a
// restart, which is cheaper than re-reading his credential every hour.
var zen *zenmoney.Client
if *zenTokenFile != "" {
raw, err := os.ReadFile(*zenTokenFile)
if err != nil {
return fmt.Errorf("read zenmoney token: %w", err)
}
zen, err = zenmoney.New(strings.TrimSpace(string(raw)), *zenURL, *timeout*3)
if err != nil {
return err
}
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
@@ -83,9 +109,13 @@ func run(args []string) error {
kumaKey: *kumaKey,
wgIface: *wgIface,
wgCmd: *wgCmd,
zen: zen,
zenEvery: *zenInterval,
}
log.Printf("mavpoll: polling every %s (netdata=%q kuma=%q wg=%q)", *interval, *netdataURL, *kumaURL, *wgIface)
// The token is never logged, not even its length.
log.Printf("mavpoll: polling every %s (netdata=%q kuma=%q wg=%q zenmoney=%v every %s)",
*interval, *netdataURL, *kumaURL, *wgIface, zen != nil, *zenInterval)
p.pollOnce(ctx) // fire immediately; don't idle a full interval on start
t := time.NewTicker(*interval)
defer t.Stop()
@@ -108,6 +138,12 @@ type poller struct {
kumaKey string
wgIface string
wgCmd string
// zen is nil unless a token file was configured — money tracking is a
// capability, off by default like weather and telegram.
zen *zenmoney.Client
zenEvery time.Duration
zenLast time.Time
}
// pollOnce — one sweep of both sources. A failure in one source logs and does
@@ -129,6 +165,66 @@ func (p *poller) pollOnce(ctx context.Context) {
log.Printf("mavpoll: wg: %v", err)
}
}
// Money on its own, much slower cadence: a bank feed that updates hourly
// polled every minute is 60 pointless reads of his financial history.
if p.zen != nil && now.Sub(p.zenLast) >= p.zenEvery {
p.zenLast = now
if err := p.pollZenmoney(ctx, now); err != nil {
log.Printf("mavpoll: zenmoney: %v", err)
}
}
}
// ---- zenmoney: spending/income totals → money facts ------------------------
// pollZenmoney reads today's and this month's totals and writes them as
// facts(kind=env, source=poll:zenmoney) (Vikunja #125).
//
// Two properties this function exists to hold:
//
// - An empty or failed read writes NOTHING. zenmoney.Summary.Value() refuses
// to encode a summary built from zero transactions, so a poller that cannot
// reach the API leaves the last good fact in place rather than overwriting
// it with a zero Maven would then recite as fact.
// - Nothing about the money leaves the box except the diff request itself, to
// the service that already holds his bank sessions. The totals are written
// to the store and read back only when he asks; they are never search input
// and no tick rule fires on them.
//
// Both windows are read from one diff call each. Two calls an hour against an
// API whose whole job is this is not worth caching.
// moneyWindow — one fact key and the period it covers.
type moneyWindow struct {
key string
from, to time.Time
}
func (p *poller) pollZenmoney(ctx context.Context, now time.Time) error {
dFrom, dTo := zenmoney.DayWindow(now)
mFrom, mTo := zenmoney.MonthWindow(now)
windows := []moneyWindow{
{zenmoney.KeySpentToday, dFrom, dTo},
{zenmoney.KeySpentMonth, mFrom, mTo},
}
var firstErr error
for _, w := range windows {
sum, err := p.zen.Since(ctx, w.from, w.to)
if err != nil {
if firstErr == nil {
firstErr = err
}
continue
}
val, ok := sum.Value()
if !ok {
// Nothing read. Silence, not a zero.
continue
}
if err := p.writeIfChangedRaw(ctx, w.key, zenmoney.Source, val, now); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
// ---- wireguard: latest handshake → presence signal -------------------------
@@ -301,6 +397,31 @@ func (p *poller) writeIfChanged(ctx context.Context, key, source, val string, no
return nil
}
// writeIfChangedRaw is writeIfChanged for values that are already JSON (the
// money facts store an object, not a string). Kept separate rather than
// generalising writeIfChanged, because the string-valued env facts encoding
// their own value is the convention the rules rely on.
//
// The log line names the key and the source, never the figures: mavpoll's log
// is not the place his spending ends up.
func (p *poller) writeIfChangedRaw(ctx context.Context, key, source, jsonVal string, now time.Time) error {
prev, err := p.core.LatestFactBySource(ctx, key, source)
switch {
case err == nil && prev.Value == jsonVal:
return nil
case err != nil && err != ipc.ErrNoFact && !isNoFact(err):
return fmt.Errorf("read %s: %w", key, err)
}
if _, err := p.core.WriteFact(ctx, ipc.WriteFactReq{
Ts: now, Kind: "env", Key: key, Value: jsonVal,
Source: source, Confidence: 1.0,
}); err != nil {
return fmt.Errorf("write %s: %w", key, err)
}
log.Printf("mavpoll: %s updated (%s)", key, source)
return nil
}
// isNoFact — ErrNoFact rehydrated over the wire is wrapped (fmt.Errorf %w), so
// errors.Is is the right check; keep a helper so the switch above reads clean.
func isNoFact(err error) bool {
+126
View File
@@ -1,8 +1,17 @@
package main
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/zenmoney"
)
func TestMaxSeverity(t *testing.T) {
@@ -62,3 +71,120 @@ func TestParseMaxHandshake(t *testing.T) {
}
}
}
// ---- zenmoney (Vikunja #125) ----------------------------------------------
// factCore records the facts the poller wrote and answers "no fact yet".
type factCore struct {
ipc.UnimplementedCoreAPI
written []ipc.WriteFactReq
prev map[string]string
}
func (c *factCore) LatestFactBySource(_ context.Context, key, source string) (ipc.Fact, error) {
if v, ok := c.prev[key+"|"+source]; ok {
return ipc.Fact{Key: key, Source: source, Value: v}, nil
}
return ipc.Fact{}, ipc.ErrNoFact
}
func (c *factCore) WriteFact(_ context.Context, req ipc.WriteFactReq) (int64, error) {
c.written = append(c.written, req)
return int64(len(c.written)), nil
}
func zenFixtureServer(t *testing.T, body []byte, status int) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if status != http.StatusOK {
w.WriteHeader(status)
return
}
w.Write(body)
}))
}
func TestPollZenmoneyWritesMoneyFacts(t *testing.T) {
body, err := os.ReadFile("../../internal/zenmoney/testdata/diff.json")
if err != nil {
t.Fatal(err)
}
srv := zenFixtureServer(t, body, http.StatusOK)
defer srv.Close()
zen, err := zenmoney.New("tok", srv.URL, time.Second)
if err != nil {
t.Fatal(err)
}
core := &factCore{}
p := &poller{core: core, zen: zen}
now := time.Date(2026, 8, 1, 21, 0, 0, 0, time.UTC)
if err := p.pollZenmoney(context.Background(), now); err != nil {
t.Fatal(err)
}
if len(core.written) != 2 {
t.Fatalf("wrote %d facts, want today + month", len(core.written))
}
for _, f := range core.written {
if f.Kind != "env" || f.Source != zenmoney.Source {
t.Errorf("fact = %+v, want kind=env source=%s", f, zenmoney.Source)
}
if _, err := zenmoney.ParseFactValue(f.Value); err != nil {
t.Errorf("fact value %q does not decode: %v", f.Value, err)
}
}
}
// A read that returns nothing for the window writes NOTHING. Silence, not a
// zero: an invented 0 would be recited back to him as fact.
func TestPollZenmoneyWritesNothingWhenEmpty(t *testing.T) {
srv := zenFixtureServer(t, []byte(`{"serverTimestamp":1,"instrument":[],"transaction":[]}`), http.StatusOK)
defer srv.Close()
zen, _ := zenmoney.New("tok", srv.URL, time.Second)
core := &factCore{}
p := &poller{core: core, zen: zen}
if err := p.pollZenmoney(context.Background(), time.Now()); err != nil {
t.Fatal(err)
}
if len(core.written) != 0 {
t.Errorf("wrote %+v, want no fact at all", core.written)
}
}
// An API failure must not overwrite the last good total either.
func TestPollZenmoneyFailureWritesNothing(t *testing.T) {
srv := zenFixtureServer(t, nil, http.StatusUnauthorized)
defer srv.Close()
zen, _ := zenmoney.New("bad", srv.URL, time.Second)
core := &factCore{}
p := &poller{core: core, zen: zen}
if err := p.pollZenmoney(context.Background(), time.Now()); err == nil {
t.Error("want the 401 reported")
}
if len(core.written) != 0 {
t.Errorf("wrote %+v on a failed read", core.written)
}
}
// Unchanged totals do not churn the facts table.
func TestWriteIfChangedRawSkipsUnchanged(t *testing.T) {
core := &factCore{prev: map[string]string{
zenmoney.KeySpentToday + "|" + zenmoney.Source: `{"count":1}`,
}}
p := &poller{core: core}
if err := p.writeIfChangedRaw(context.Background(), zenmoney.KeySpentToday, zenmoney.Source, `{"count":1}`, time.Now()); err != nil {
t.Fatal(err)
}
if len(core.written) != 0 {
t.Errorf("wrote %+v for an unchanged value", core.written)
}
}
// Money tracking is off unless configured: no token file, no zenmoney client,
// and the poller still refuses to start with nothing at all to poll.
func TestRunRequiresSomethingToPoll(t *testing.T) {
err := run([]string{"-socket", "/tmp/nope.sock", "-netdata", "", "-kuma", "", "-wg", ""})
if err == nil || !strings.Contains(err.Error(), "nothing to poll") {
t.Errorf("err = %v, want a 'nothing to poll' refusal", err)
}
}
+49 -6
View File
@@ -25,6 +25,7 @@ import (
"github.com/kami/maven/internal/audio"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/pattern"
"github.com/kami/maven/internal/tasks"
"github.com/kami/maven/internal/voice"
"github.com/kami/maven/internal/webauthn"
)
@@ -840,6 +841,10 @@ type taskRow struct {
Due string
Created string
Resolved string
// Why — the ranker's reason for this row's position (Vikunja #129), in
// Russian, empty when nothing distinguished the task. Blank is the honest
// rendering: he never said this one mattered more.
Why string
}
// handleTasks serves the task review surface (GET) and the four writes it
@@ -878,20 +883,46 @@ func handleTasks(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
http.Error(w, "tasks error: "+err.Error(), http.StatusBadGateway)
return
}
var cands, open, resolved []taskRow
// Live rows are ordered by the same ranker the spoken list uses, so the page
// and the voice reply can never disagree about what comes first. Resolved
// rows keep store order (newest first) — ranking finished work is pointless.
var live []tasks.Item
var resolved []taskRow
for _, t := range all {
switch t.Status {
case "candidate", "open":
live = append(live, tasks.Item{
ID: t.ID, Text: t.Text, Status: t.Status,
Created: t.CreatedTs, Due: t.Due, Weight: t.Weight,
})
default:
resolved = append(resolved, taskRow{
ID: t.ID, Text: t.Text, Source: t.Source, Evidence: t.Evidence,
Status: t.Status, Created: fmtTaskTime(&t.CreatedTs),
Due: fmtTaskDate(t.Due), Resolved: fmtTaskTime(t.Resolved),
})
}
}
byID := make(map[int64]ipc.Task, len(all))
for _, t := range all {
byID[t.ID] = t
}
var cands, open []taskRow
for _, r := range tasks.Rank(live, time.Now()) {
t := byID[r.ID]
row := taskRow{
ID: t.ID, Text: t.Text, Source: t.Source, Evidence: t.Evidence,
Status: t.Status, Created: fmtTaskTime(&t.CreatedTs),
Due: fmtTaskDate(t.Due), Resolved: fmtTaskTime(t.Resolved),
Why: r.Reason,
}
switch t.Status {
case "candidate":
if t.Status == "candidate" {
// A candidate's due date is Maven's reading of a mail, so its
// ranking reason is not shown as if he had set a priority.
row.Why = ""
cands = append(cands, row)
case "open":
} else {
open = append(open, row)
default:
resolved = append(resolved, row)
}
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
@@ -916,6 +947,18 @@ func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (stri
return "", errors.New("empty task text")
}
req := ipc.CaptureTaskReq{Text: text, Source: "tap:web", Status: "open", Ts: time.Now()}
// Importance is his, stated on the form. Out-of-range values are
// clamped rather than rejected — a bad select is not worth a 400.
if v := r.FormValue("weight"); v != "" {
var wgt int
if n, _ := fmt.Sscanf(v, "%d", &wgt); n != 1 || wgt < 0 {
return "", fmt.Errorf("bad weight %q", v)
}
if wgt > tasks.MaxWeight {
wgt = tasks.MaxWeight
}
req.Weight = wgt
}
if d := r.FormValue("due"); d != "" {
due, err := time.ParseInLocation("2006-01-02", d, time.Local)
if err != nil {
+8 -1
View File
@@ -9,6 +9,11 @@
<input type=hidden name=action value=add>
<input type=text name=text placeholder="что нужно сделать" size=44 required>
<input type=date name=due title="due date (optional)">
<select name=weight title="importance (optional)">
<option value=0>normal</option>
<option value=2>важно</option>
<option value=3>срочно</option>
</select>
<button class=btn>add</button>
</form>
</section>
@@ -38,10 +43,12 @@
<section class=card>
<h2 class=card-title>open <span class=badge>{{len .Open}}</span></h2>
<div class=hint>most pressing first — by the deadlines and the urgency you gave, nothing guessed.</div>
{{if .Open}}<div class=scroll><table>
<tr><th>task</th><th>from</th><th>due</th><th>captured</th><th></th><th></th></tr>
<tr><th>task</th><th>why</th><th>from</th><th>due</th><th>captured</th><th></th><th></th></tr>
{{range .Open}}<tr>
<td class=text-max>{{.Text}}</td>
<td class=hint>{{.Why}}</td>
<td class=hint>{{.Source}}</td>
<td>{{.Due}}</td>
<td class=muted>{{.Created}}</td>
+59
View File
@@ -10,6 +10,7 @@ import (
"time"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/tasks"
)
// fakeTaskCore serves the /tasks handler: a canned list plus a log of the
@@ -164,3 +165,61 @@ func TestHandleTasksNoCore(t *testing.T) {
t.Errorf("status = %d, want 503", rec.Code)
}
}
// The open list is ordered by the ranker, and the reason is shown so the page
// says why a task is first instead of asking him to trust the order.
func TestHandleTasksOrdersOpenByRank(t *testing.T) {
now := time.Now()
due := now
core := &fakeTaskCore{tasks: []ipc.Task{
{ID: 1, Text: "купить молоко", Status: "open", CreatedTs: now},
{ID: 2, Text: "оплатить интернет", Status: "open", CreatedTs: now, Due: &due},
}}
rec := httptest.NewRecorder()
handleTasks(rec, httptest.NewRequest(http.MethodGet, "/tasks", nil), core)
body := rec.Body.String()
if strings.Index(body, "оплатить интернет") > strings.Index(body, "купить молоко") {
t.Error("want the dated task rendered first")
}
if !strings.Contains(body, "сегодня") {
t.Error("want the ranker's reason shown in the why column")
}
}
// A candidate is ranked into place but never carries a priority reason: its due
// date is Maven's reading of a mail, not something he stated.
func TestHandleTasksHidesCandidateReason(t *testing.T) {
now := time.Now()
due := now
core := &fakeTaskCore{tasks: []ipc.Task{
{ID: 1, Text: "продлить страховку", Status: "candidate", CreatedTs: now, Due: &due},
}}
rec := httptest.NewRecorder()
handleTasks(rec, httptest.NewRequest(http.MethodGet, "/tasks", nil), core)
if strings.Contains(rec.Body.String(), "сегодня") {
t.Error("a candidate must not be shown with a priority reason")
}
}
func TestApplyTaskPostCarriesWeight(t *testing.T) {
core := &fakeTaskCore{created: true}
form := url.Values{"action": {"add"}, "text": {"оплатить интернет"}, "weight": {"3"}}
req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
handleTasks(httptest.NewRecorder(), req, core)
if len(core.captured) != 1 || core.captured[0].Weight != 3 {
t.Fatalf("captured = %+v, want weight 3", core.captured)
}
}
// Out of range clamps rather than 400s; a non-number is a real client error.
func TestApplyTaskPostClampsWeight(t *testing.T) {
core := &fakeTaskCore{created: true}
form := url.Values{"action": {"add"}, "text": {"что-то"}, "weight": {"99"}}
req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
handleTasks(httptest.NewRecorder(), req, core)
if core.captured[0].Weight != tasks.MaxWeight {
t.Errorf("weight = %d, want the cap", core.captured[0].Weight)
}
}
+8
View File
@@ -101,9 +101,17 @@ services:
"-netdata", "http://127.0.0.1:19999",
"-kuma", "http://127.0.0.1:3001/metrics",
"-kuma-key", "uk5_mavpoll-key"]
# Money tracking (Vikunja #125) is OFF: it needs a zenmoney token,
# which mavpoll reads from a FILE so it never appears in `ps`, in
# this file, or in shell history. To enable, mount the token and
# append: "-zenmoney-token-file", "/run/secrets/zenmoney.token"
# (optionally "-zenmoney-interval", "1h"). Core never sees the
# token — the poller writes facts(kind=env, source=poll:zenmoney)
# and mavend only reads those back when he asks.
depends_on: [mavend]
volumes:
- sockets:/run/maven
# - ./deploy/zenmoney.token:/run/secrets/zenmoney.token:ro
volumes:
dbdata:
+71
View File
@@ -0,0 +1,71 @@
package router
import "strings"
// Money questions, matched deterministically (Vikunja #125).
//
// No new intent, for the same reason as tasks: the intent enum is a contract
// with the relabelling prompt. "сколько я потратил?" is a query; which figure
// it asks for is a lookup, not something to ask a 1.7B — and a model asked to
// invent a spending total will happily do it.
// MoneyWindow — which period a money question asks about.
type MoneyWindow int
const (
MoneyNone MoneyWindow = iota
MoneyToday
MoneyMonth
)
// moneyNouns — the words that make a question be about his money.
var moneyNouns = []string{
"потратил", "потратила", "тратил", "траты", "трат", "расходы", "расходов",
"заработал", "потрачено", "денег", "spend", "spent", "expenses",
}
// ParseMoneyQuery reports whether an utterance asks about spending or income,
// and over which window. Defaults to the month: "сколько я потратил?" without a
// period is the month-to-date question, which is the one worth answering.
//
// Narrow on purpose. A money noun alone is not enough — "я потратил весь день
// на это" is him talking about his day, so an amount word or an explicit
// question word has to be there too.
func ParseMoneyQuery(text string) (MoneyWindow, bool) {
toks := planTokens(text)
if len(toks) == 0 {
return MoneyNone, false
}
hasNoun := false
for _, t := range toks {
for _, n := range moneyNouns {
if t == n {
hasNoun = true
}
}
}
if !hasNoun {
return MoneyNone, false
}
// "весь день", "время", "силы" — spending that is not money.
for _, t := range toks {
switch t {
case "день", "дня", "время", "времени", "силы", "сил", "нервы":
return MoneyNone, false
}
}
asking := hasTok(toks, "сколько") || hasTok(toks, "какие") || hasTok(toks, "покажи") ||
hasTok(toks, "how") || hasTok(toks, "much") || hasTok(toks, "my") ||
hasTok(toks, "мои") || hasTok(toks, "траты") || hasTok(toks, "расходы")
if !asking {
return MoneyNone, false
}
lower := strings.ToLower(text)
switch {
case hasTok(toks, "сегодня") || strings.Contains(lower, "today"):
return MoneyToday, true
case hasTok(toks, "месяц") || hasTok(toks, "месяце") || strings.Contains(lower, "month"):
return MoneyMonth, true
}
return MoneyMonth, true
}
+31
View File
@@ -0,0 +1,31 @@
package router
import "testing"
func TestParseMoneyQuery(t *testing.T) {
cases := []struct {
in string
window MoneyWindow
ok bool
}{
{"сколько я потратил сегодня?", MoneyToday, true},
{"сколько я потратил в этом месяце?", MoneyMonth, true},
{"сколько я потратил?", MoneyMonth, true}, // month-to-date by default
{"покажи мои траты", MoneyMonth, true},
{"какие у меня расходы за месяц", MoneyMonth, true},
{"how much did I spend today", MoneyToday, true},
{"сколько я заработал в этом месяце", MoneyMonth, true},
// Not about money.
{"я потратил весь день на это", MoneyNone, false},
{"потратил много сил", MoneyNone, false},
{"какая погода?", MoneyNone, false},
{"я купил молоко", MoneyNone, false},
{"", MoneyNone, false},
}
for _, c := range cases {
w, ok := ParseMoneyQuery(c.in)
if ok != c.ok || w != c.window {
t.Errorf("ParseMoneyQuery(%q) = (%v, %v), want (%v, %v)", c.in, w, ok, c.window, c.ok)
}
}
}
+51 -4
View File
@@ -38,11 +38,33 @@ var taskCapturePrefixes = []string{
"new task",
}
// TaskCapture — a parsed capture: the task itself, plus the importance he
// stated out loud if he stated one (Vikunja #129). Weight 0 means he said
// nothing about importance, which the ranker treats as exactly that — no
// urgency is inferred from the wording.
type TaskCapture struct {
Text string
Weight int
}
// urgencyMarkers — the words that set a weight, strongest first. Only these
// two rungs: "срочно" is a deadline he has not named, "важно" is a preference,
// and a third shade of urgent would be a distinction he never makes out loud.
var urgencyMarkers = []struct {
word string
weight int
}{
{"срочно", 3},
{"urgent", 3},
{"важно", 2},
{"important", 2},
}
// ParseTaskCapture reports whether an utterance explicitly files a task, and
// returns the task text with the marker stripped. A marker with nothing after it
// is not a capture (there is no task in "добавь в задачи") — the caller falls
// through to whatever it would otherwise have done with the turn.
func ParseTaskCapture(text string) (string, bool) {
func ParseTaskCapture(text string) (TaskCapture, bool) {
trimmed := strings.TrimSpace(text)
lower := strings.ToLower(trimmed)
best := ""
@@ -52,7 +74,7 @@ func ParseTaskCapture(text string) (string, bool) {
}
}
if best == "" {
return "", false
return TaskCapture{}, false
}
// Cut on the rune length of the matched prefix. ToLower does not change the
// byte length of Russian or English letters, so the index carries over.
@@ -60,10 +82,35 @@ func ParseTaskCapture(text string) (string, bool) {
rest = strings.TrimLeft(rest, ":—- ")
rest = strings.TrimSpace(rest)
rest = strings.TrimRight(rest, ".!")
rest, weight := stripUrgency(rest)
if rest == "" {
return "", false
return TaskCapture{}, false
}
return rest, true
return TaskCapture{Text: rest, Weight: weight}, true
}
// stripUrgency pulls a leading or trailing urgency word out of the task text
// and returns the weight it implies. Only at the edges: "срочно оплатить
// интернет" and "оплатить интернет срочно" are the same instruction, while
// "позвонить в срочную помощь" is a task whose text happens to contain the
// stem, and cutting a word out of the middle of it would mangle the task.
//
// The word is removed from the text, because the list should read "оплатить
// интернет (важно)" and not "важно оплатить интернет (важно)".
func stripUrgency(text string) (string, int) {
for _, m := range urgencyMarkers {
lower := strings.ToLower(text)
switch {
case strings.HasPrefix(lower, m.word+" "):
return strings.TrimSpace(text[len(m.word):]), m.weight
case strings.HasSuffix(lower, " "+m.word):
return strings.TrimSpace(text[:len(text)-len(m.word)]), m.weight
case lower == m.word:
// Nothing but the marker — no task in it.
return "", 0
}
}
return text, 0
}
// taskListWords — the nouns that make a question be about the task list.
+25 -17
View File
@@ -4,28 +4,36 @@ import "testing"
func TestParseTaskCapture(t *testing.T) {
cases := []struct {
in string
text string
ok bool
in string
text string
weight int
ok bool
}{
{"добавь в задачи купить молоко", "купить молоко", true},
{"Добавь в список дел: позвонить в банк", "позвонить в банк", true},
{"запиши задачу починить кран.", "починить кран", true},
{"новая задача — оплатить интернет", "оплатить интернет", true},
{"add a task buy milk", "buy milk", true},
{"добавь в задачи купить молоко", "купить молоко", 0, true},
{"Добавь в список дел: позвонить в банк", "позвонить в банк", 0, true},
{"запиши задачу починить кран.", "починить кран", 0, true},
{"новая задача — оплатить интернет", "оплатить интернет", 0, true},
{"add a task buy milk", "buy milk", 0, true},
// Urgency he stated out loud, leading or trailing, stripped from the text.
{"добавь в задачи срочно оплатить интернет", "оплатить интернет", 3, true},
{"добавь в задачи оплатить интернет срочно", "оплатить интернет", 3, true},
{"новая задача важно позвонить маме", "позвонить маме", 2, true},
// The stem inside the task text is part of the task, not a marker.
{"добавь в задачи позвонить в срочную помощь", "позвонить в срочную помощь", 0, true},
// A marker with nothing after it files nothing.
{"добавь в задачи", "", false},
{"новая задача", "", false},
{"добавь в задачи", "", 0, false},
{"новая задача", "", 0, false},
{"добавь в задачи срочно", "", 0, false},
// Not a capture: he is talking, not filing.
{"надо бы поспать", "", false},
{"я не добавил молоко в список", "", false},
{"какие у меня задачи?", "", false},
{"", "", false},
{"надо бы поспать", "", 0, false},
{"я не добавил молоко в список", "", 0, false},
{"какие у меня задачи?", "", 0, false},
{"", "", 0, false},
}
for _, c := range cases {
text, ok := ParseTaskCapture(c.in)
if ok != c.ok || text != c.text {
t.Errorf("ParseTaskCapture(%q) = (%q, %v), want (%q, %v)", c.in, text, ok, c.text, c.ok)
got, ok := ParseTaskCapture(c.in)
if ok != c.ok || got.Text != c.text || got.Weight != c.weight {
t.Errorf("ParseTaskCapture(%q) = (%+v, %v), want (%q, w=%d, %v)", c.in, got, ok, c.text, c.weight, c.ok)
}
}
}
+238
View File
@@ -0,0 +1,238 @@
// Package tasks ranks captured work (Vikunja #129).
//
// The ordering is COMPUTED, not generated. Asking a 1.7B model which of his
// tasks matters most would produce a fluent opinion about his life with no
// basis in anything, and a confidently wrong priority is worse than no
// priority at all — the same reasoning as the behaviour profile in
// internal/memory, which counts instead of summarising.
//
// So: four signals, all of them things he told her, and a reason string naming
// the one that decided each row. Nothing here invents urgency. A task with no
// due date and no weight scores nothing and sits where its age puts it, which
// is the honest answer to "which of these matters?" when he never said.
//
// Ranking is a READ. It sorts and renders; it never writes, schedules or
// announces. Maven is not a nag: a task rising to the top of this list is not a
// reason to speak, only the order she recites in when asked.
package tasks
import (
"fmt"
"sort"
"strings"
"time"
)
// Status values, mirroring internal/store so a caller can rank ipc.Task rows
// without importing the store.
const (
StatusCandidate = "candidate"
StatusOpen = "open"
)
// Item — one task to rank. The subset of a task that ranking depends on;
// callers map their own row type onto it.
type Item struct {
ID int64
Text string
Status string
Created time.Time
Due *time.Time
Weight int
}
// Ranked — one task with its score and the reason that decided it.
type Ranked struct {
Item
Score float64
// Reason — the dominant signal, in Russian, for the page and the spoken
// list. Empty when nothing distinguished this task: no due date, no
// weight, not old. Saying "потому что" about a task he never prioritised
// would be making something up.
Reason string
}
// Scoring weights. Deliberately coarse round numbers: this is a knob, not
// math, and the only property that has to hold is the ordering between classes
// (overdue beats today beats this week beats undated).
const (
scoreOverdue = 100 // he already missed it
scoreOverduePer = 5 // per further day late, capped
scoreOverdueCap = 40
scoreDueToday = 60
scoreDueTomorrow = 40
scoreDueWeek = 20
scoreDueLater = 5
scorePerWeight = 15 // "срочно" / "важно" / the web form's select
scorePerWeekOld = 1 // so nothing rots at the bottom forever
scoreAgeCap = 10
// MaxWeight — the highest importance hint capture accepts. Three rungs is
// as many as anyone can rank by hand honestly.
MaxWeight = 3
)
// Rank scores every item and returns them ordered: confirmed work first, then
// candidates, each by score descending, oldest first on a tie.
//
// Candidates never outrank open work, whatever their due date. A task Maven
// derived from something she read is a suggestion until he confirms it, and
// putting her guess above his own stated work would be reading his priorities
// back to him wrong.
func Rank(items []Item, now time.Time) []Ranked {
out := make([]Ranked, 0, len(items))
for _, it := range items {
score, reason := score(it, now)
out = append(out, Ranked{Item: it, Score: score, Reason: reason})
}
sort.SliceStable(out, func(i, j int) bool {
ci, cj := out[i].Status == StatusCandidate, out[j].Status == StatusCandidate
if ci != cj {
return !ci // open before candidate
}
if out[i].Score != out[j].Score {
return out[i].Score > out[j].Score
}
return out[i].Created.Before(out[j].Created) // oldest first, FIFO
})
return out
}
// score — the per-item scoring function. Returns the score and the dominant
// reason. Deadline beats weight when both are present: a date is a fact about
// the world, a weight is how he felt when he filed it.
func score(it Item, now time.Time) (float64, string) {
var total float64
reason := ""
if it.Due != nil {
days := dayDelta(*it.Due, now)
switch {
case days < 0:
late := -days
bonus := float64(late * scoreOverduePer)
if bonus > scoreOverdueCap {
bonus = scoreOverdueCap
}
total += scoreOverdue + bonus
reason = "просрочено"
if late == 1 {
reason = "просрочено на день"
} else if late > 1 {
reason = fmt.Sprintf("просрочено на %d дн.", late)
}
case days == 0:
total += scoreDueToday
reason = "сегодня"
case days == 1:
total += scoreDueTomorrow
reason = "завтра"
case days <= 7:
total += scoreDueWeek
reason = fmt.Sprintf("через %d дн.", days)
default:
total += scoreDueLater
}
}
w := it.Weight
if w > MaxWeight {
w = MaxWeight
}
if w > 0 {
total += float64(w * scorePerWeight)
if reason == "" {
reason = "важно"
}
}
if !it.Created.IsZero() {
weeks := int(now.Sub(it.Created).Hours() / (24 * 7))
if weeks > 0 {
age := float64(weeks * scorePerWeekOld)
if age > scoreAgeCap {
age = scoreAgeCap
}
total += age
if reason == "" && weeks >= 2 {
reason = "давно в списке"
}
}
}
return total, reason
}
// dayDelta — calendar days from now to due, in due's own location. Whole days,
// not hours: a task due today is due today whether it is 09:00 or 23:00, and an
// hours-based comparison would call this evening's task "overdue" all afternoon.
func dayDelta(due, now time.Time) int {
loc := due.Location()
d := time.Date(due.Year(), due.Month(), due.Day(), 0, 0, 0, 0, loc)
n := now.In(loc)
n = time.Date(n.Year(), n.Month(), n.Day(), 0, 0, 0, 0, loc)
return int(d.Sub(n).Hours() / 24)
}
// SpokenLimit — how many tasks the spoken list names before it summarises the
// rest. A recital of twenty items is noise; five is a list he can hold.
const SpokenLimit = 5
// FormatRU renders a ranked list the way Maven says it. Confirmed work first,
// with the reason attached where there is one; candidates named as
// unconfirmed, never recited as his work.
//
// One renderer for the voice reply and the web page, for the same reason
// DayPlan.Spoken is built core-side: two formatters drift, and then she says
// one order and shows another.
func FormatRU(ranked []Ranked) string {
var open, cands []Ranked
for _, r := range ranked {
if r.Status == StatusCandidate {
cands = append(cands, r)
} else {
open = append(open, r)
}
}
if len(open) == 0 && len(cands) == 0 {
return "задач нет."
}
var b strings.Builder
if len(open) > 0 {
b.WriteString("сначала: ")
b.WriteString(joinRU(open, SpokenLimit, true))
b.WriteString(".")
}
if len(cands) > 0 {
if b.Len() > 0 {
b.WriteString(" ")
}
b.WriteString("ещё я нашла, но ты не подтвердил: ")
b.WriteString(joinRU(cands, SpokenLimit, false))
b.WriteString(".")
}
return b.String()
}
// joinRU lists up to limit tasks, then says how many are left. withReasons
// attaches the parenthesised reason — candidates are listed bare, since their
// due dates are Maven's reading of a mail and not something he stated.
func joinRU(rs []Ranked, limit int, withReasons bool) string {
shown := rs
rest := 0
if len(rs) > limit {
shown, rest = rs[:limit], len(rs)-limit
}
parts := make([]string, 0, len(shown))
for _, r := range shown {
if withReasons && r.Reason != "" {
parts = append(parts, r.Text+" ("+r.Reason+")")
} else {
parts = append(parts, r.Text)
}
}
s := strings.Join(parts, "; ")
if rest > 0 {
s += fmt.Sprintf("; и ещё %d", rest)
}
return s
}
+177
View File
@@ -0,0 +1,177 @@
package tasks
import (
"strings"
"testing"
"time"
)
func at(y int, m time.Month, d int) *time.Time {
t := time.Date(y, m, d, 0, 0, 0, 0, time.UTC)
return &t
}
func now() time.Time { return time.Date(2026, 8, 1, 14, 0, 0, 0, time.UTC) }
func texts(rs []Ranked) []string {
out := make([]string, len(rs))
for i, r := range rs {
out[i] = r.Text
}
return out
}
func TestRankOrdersByDeadline(t *testing.T) {
items := []Item{
{ID: 1, Text: "через неделю", Status: StatusOpen, Due: at(2026, 8, 7), Created: now()},
{ID: 2, Text: "просрочено", Status: StatusOpen, Due: at(2026, 7, 28), Created: now()},
{ID: 3, Text: "без срока", Status: StatusOpen, Created: now()},
{ID: 4, Text: "сегодня", Status: StatusOpen, Due: at(2026, 8, 1), Created: now()},
{ID: 5, Text: "завтра", Status: StatusOpen, Due: at(2026, 8, 2), Created: now()},
}
got := texts(Rank(items, now()))
want := []string{"просрочено", "сегодня", "завтра", "через неделю", "без срока"}
for i := range want {
if got[i] != want[i] {
t.Fatalf("order = %v, want %v", got, want)
}
}
}
func TestRankCandidatesNeverOutrankOpenWork(t *testing.T) {
items := []Item{
{ID: 1, Text: "его задача", Status: StatusOpen, Created: now()},
// Everything about this one screams urgent — and it is still a guess.
{ID: 2, Text: "из письма", Status: StatusCandidate, Due: at(2026, 7, 1), Weight: 3, Created: now()},
}
got := Rank(items, now())
if got[0].Text != "его задача" {
t.Errorf("order = %v, want his own work first", texts(got))
}
}
func TestRankWeightLiftsUndatedWork(t *testing.T) {
items := []Item{
{ID: 1, Text: "обычная", Status: StatusOpen, Created: now()},
{ID: 2, Text: "важная", Status: StatusOpen, Weight: 2, Created: now()},
}
got := Rank(items, now())
if got[0].Text != "важная" {
t.Errorf("order = %v, want the weighted task first", texts(got))
}
if got[0].Reason != "важно" {
t.Errorf("reason = %q, want важно", got[0].Reason)
}
// A deadline still beats a weight: a date is a fact, a weight is a feeling.
items = append(items, Item{ID: 3, Text: "сегодня", Status: StatusOpen, Due: at(2026, 8, 1), Created: now()})
got = Rank(items, now())
if got[0].Text != "сегодня" {
t.Errorf("order = %v, want the dated task first", texts(got))
}
}
func TestRankOldestFirstOnATie(t *testing.T) {
old := now().AddDate(0, 0, -3)
items := []Item{
{ID: 1, Text: "новая", Status: StatusOpen, Created: now()},
{ID: 2, Text: "старая", Status: StatusOpen, Created: old},
}
got := Rank(items, now())
if got[0].Text != "старая" {
t.Errorf("order = %v, want FIFO on equal urgency", texts(got))
}
}
func TestRankNoInventedReason(t *testing.T) {
got := Rank([]Item{{ID: 1, Text: "что-то", Status: StatusOpen, Created: now()}}, now())
if got[0].Reason != "" {
t.Errorf("reason = %q — nothing distinguished this task, so there is nothing to say", got[0].Reason)
}
if got[0].Score != 0 {
t.Errorf("score = %v, want 0", got[0].Score)
}
}
func TestRankAgeIsCappedAndNamed(t *testing.T) {
items := []Item{
{ID: 1, Text: "прошлогодняя", Status: StatusOpen, Created: now().AddDate(-1, 0, 0)},
{ID: 2, Text: "трёхнедельная", Status: StatusOpen, Created: now().AddDate(0, 0, -21)},
}
got := Rank(items, now())
if got[0].Score != scoreAgeCap {
t.Errorf("oldest score = %v, want the cap %v", got[0].Score, float64(scoreAgeCap))
}
if got[0].Reason != "давно в списке" {
t.Errorf("reason = %q", got[0].Reason)
}
}
// A task due at 23:00 today is due today, not overdue since this morning.
func TestRankDueTodayIsNotOverdue(t *testing.T) {
due := time.Date(2026, 8, 1, 23, 0, 0, 0, time.UTC)
got := Rank([]Item{{ID: 1, Text: "вечером", Status: StatusOpen, Due: &due, Created: now()}}, now())
if got[0].Reason != "сегодня" {
t.Errorf("reason = %q, want сегодня", got[0].Reason)
}
}
func TestRankOverdueDaysAreCounted(t *testing.T) {
got := Rank([]Item{
{ID: 1, Text: "вчера", Status: StatusOpen, Due: at(2026, 7, 31), Created: now()},
{ID: 2, Text: "давно", Status: StatusOpen, Due: at(2026, 7, 20), Created: now()},
}, now())
if got[0].Text != "давно" {
t.Errorf("order = %v, want the later-overdue task first", texts(got))
}
if got[0].Reason != "просрочено на 12 дн." {
t.Errorf("reason = %q", got[0].Reason)
}
if got[1].Reason != "просрочено на день" {
t.Errorf("reason = %q", got[1].Reason)
}
}
func TestFormatRUNamesReasonsAndSeparatesCandidates(t *testing.T) {
ranked := Rank([]Item{
{ID: 1, Text: "оплатить интернет", Status: StatusOpen, Due: at(2026, 8, 1), Created: now()},
{ID: 2, Text: "купить молоко", Status: StatusOpen, Created: now()},
{ID: 3, Text: "продлить страховку", Status: StatusCandidate, Due: at(2026, 7, 1), Created: now()},
}, now())
got := FormatRU(ranked)
if !strings.HasPrefix(got, "сначала: оплатить интернет (сегодня)") {
t.Errorf("reply = %q", got)
}
if !strings.Contains(got, "не подтвердил: продлить страховку") {
t.Errorf("candidate not named as unconfirmed: %q", got)
}
// A candidate's due date is Maven's reading of a mail, not his statement.
if strings.Contains(got, "продлить страховку (") {
t.Errorf("a candidate must be listed without a reason: %q", got)
}
// Persona: nothing masculine, no pet names, informal address only.
for _, bad := range []string{"рад ", "понял ", "милый", "дорогой", "вам", "ваши"} {
if strings.Contains(got, bad) {
t.Errorf("reply %q contains %q", got, bad)
}
}
}
func TestFormatRUCapsTheSpokenList(t *testing.T) {
var items []Item
for i := 0; i < SpokenLimit+3; i++ {
items = append(items, Item{ID: int64(i), Text: "задача", Status: StatusOpen, Created: now()})
}
got := FormatRU(Rank(items, now()))
if !strings.Contains(got, "и ещё 3") {
t.Errorf("reply = %q, want the tail summarised", got)
}
if strings.Count(got, "задача") != SpokenLimit {
t.Errorf("reply = %q, want exactly %d named", got, SpokenLimit)
}
}
func TestFormatRUEmpty(t *testing.T) {
if got := FormatRU(nil); got != "задач нет." {
t.Errorf("reply = %q", got)
}
}
+267
View File
@@ -0,0 +1,267 @@
// Package zenmoney reads spending and income from ZenMoney's /v8/diff/ API
// (Vikunja #125).
//
// Trust boundary: ZenMoney, not Maven. They already hold his bank sessions —
// this package only reads back what they have, over a token that lives in the
// poller module and is never handed to core. Nothing here writes to ZenMoney;
// diff is called read-only (an empty change set in, a change set out).
//
// Two rules the code exists to enforce:
//
// - NEVER invent a number. Every figure in a Summary is a sum of amounts the
// API returned. A request that fails, or returns nothing, produces no
// summary and therefore no fact — silence, not a zero. A confidently wrong
// "ты потратил 0" is worse than no answer.
// - His money is never search input. This package holds no notes, no
// utterances and no persona text, and it has no path to the external search
// capability. The only thing that leaves the box here is the diff request
// itself, to the service that already has the data.
package zenmoney
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"sort"
"strings"
"time"
)
// DefaultBaseURL — ZenMoney's API root. Overridable so the tests can point at
// an httptest server replaying a recorded response.
const DefaultBaseURL = "https://api.zenmoney.ru"
// Client is a ZenMoney diff reader. The token is held here, in the poller's
// address space; core never receives it and never learns it exists.
type Client struct {
BaseURL string
Token string
HTTP *http.Client
}
// New returns a client with a bounded HTTP timeout. An empty token is a
// programming error the caller must catch — the capability is off unless
// configured, so a client is only ever built when a token was supplied.
func New(token, baseURL string, timeout time.Duration) (*Client, error) {
if strings.TrimSpace(token) == "" {
return nil, fmt.Errorf("zenmoney: empty token")
}
if baseURL == "" {
baseURL = DefaultBaseURL
}
if timeout <= 0 {
timeout = 20 * time.Second
}
return &Client{
BaseURL: strings.TrimRight(baseURL, "/"),
Token: token,
HTTP: &http.Client{Timeout: timeout},
}, nil
}
// diffRequest — the smallest body /v8/diff/ accepts. serverTimestamp is the
// incremental cursor: the server returns objects changed at or after it.
type diffRequest struct {
CurrentClientTimestamp int64 `json:"currentClientTimestamp"`
ServerTimestamp int64 `json:"serverTimestamp"`
}
// diffResponse — only the fields spending needs. ZenMoney returns a dozen more
// object types (tags, merchants, budgets, reminders); decoding them would mean
// holding more of his financial life in memory than the question needs.
type diffResponse struct {
ServerTimestamp int64 `json:"serverTimestamp"`
Instrument []instrument `json:"instrument"`
Transaction []transaction `json:"transaction"`
}
type instrument struct {
ID int64 `json:"id"`
ShortTitle string `json:"shortTitle"`
}
type transaction struct {
ID string `json:"id"`
Date string `json:"date"` // "2026-07-15"
Deleted bool `json:"deleted"`
Income float64 `json:"income"`
Outcome float64 `json:"outcome"`
IncomeInstrument int64 `json:"incomeInstrument"`
OutcomeInstrmnt int64 `json:"outcomeInstrument"`
IncomeAccount string `json:"incomeAccount"`
OutcomeAccount string `json:"outcomeAccount"`
}
// Money — an amount in one currency. Kept as the currency's own short title
// ("RUB", "EUR") rather than converted: ZenMoney's rates are a snapshot, and
// converting would turn a figure he can check against his bank into one he
// cannot.
type Money struct {
Currency string `json:"currency"`
Amount float64 `json:"amount"`
}
// Summary — what was spent and earned over a window, per currency, plus how
// many transactions it was computed from. Count is the honesty check: a
// summary built from zero transactions is not "you spent nothing", it is "there
// was nothing to read", and callers treat it as no answer.
type Summary struct {
From, To time.Time
Spent []Money `json:"spent"`
Earned []Money `json:"earned"`
Count int `json:"count"`
// ServerTimestamp — the cursor the API returned, for the caller to log or
// carry. Not used as an incremental cursor for summaries; see Since.
ServerTimestamp int64 `json:"-"`
}
// Since returns the summary of transactions dated in [from, to).
//
// The diff cursor is set to `from` so the server only sends objects changed
// since then, which for a "this month" window is everything filed this month.
// The caveat, deliberately accepted: a transaction he EDITED this month but
// dated last month arrives too, and is then excluded by date — so editing old
// records cannot inflate this month's total. The reverse case (a transaction
// dated this month, filed and last changed before `from`) cannot exist.
func (c *Client) Since(ctx context.Context, from, to time.Time) (Summary, error) {
resp, err := c.diff(ctx, from.Unix())
if err != nil {
return Summary{}, err
}
return summarize(resp, from, to), nil
}
func (c *Client) diff(ctx context.Context, serverTimestamp int64) (diffResponse, error) {
body, err := json.Marshal(diffRequest{
CurrentClientTimestamp: time.Now().Unix(),
ServerTimestamp: serverTimestamp,
})
if err != nil {
return diffResponse{}, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/v8/diff/", bytes.NewReader(body))
if err != nil {
return diffResponse{}, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.Token)
hc := c.HTTP
if hc == nil {
hc = &http.Client{Timeout: 20 * time.Second}
}
res, err := hc.Do(req)
if err != nil {
return diffResponse{}, err
}
defer res.Body.Close()
raw, err := io.ReadAll(io.LimitReader(res.Body, 32<<20))
if err != nil {
return diffResponse{}, err
}
if res.StatusCode != http.StatusOK {
// The status only. The body of a failed diff can echo account data, and
// this string reaches the log.
return diffResponse{}, fmt.Errorf("zenmoney diff: %s", res.Status)
}
var out diffResponse
if err := json.Unmarshal(raw, &out); err != nil {
return diffResponse{}, fmt.Errorf("zenmoney diff: decode: %w", err)
}
return out, nil
}
// summarize sums the transactions dated inside the window.
//
// Excluded, in order: deleted rows (ZenMoney tombstones rather than removes),
// transfers and currency exchanges (income and outcome both non-zero — moving
// his own money between his own accounts is not spending), and anything dated
// outside the window.
func summarize(resp diffResponse, from, to time.Time) Summary {
cur := map[int64]string{}
for _, in := range resp.Instrument {
cur[in.ID] = in.ShortTitle
}
spent := map[string]float64{}
earned := map[string]float64{}
count := 0
for _, t := range resp.Transaction {
if t.Deleted {
continue
}
d, err := time.ParseInLocation("2006-01-02", t.Date, from.Location())
if err != nil {
continue // an undated row is not a number we can place
}
if d.Before(from) || !d.Before(to) {
continue
}
if t.Income > 0 && t.Outcome > 0 {
continue // transfer / exchange
}
switch {
case t.Outcome > 0:
spent[currency(cur, t.OutcomeInstrmnt)] += t.Outcome
count++
case t.Income > 0:
earned[currency(cur, t.IncomeInstrument)] += t.Income
count++
}
}
return Summary{
From: from, To: to,
Spent: sortMoney(spent), Earned: sortMoney(earned),
Count: count, ServerTimestamp: resp.ServerTimestamp,
}
}
// currency names the instrument, or says it does not know. An unknown id keeps
// the amount rather than dropping it: a sum without a currency label is still
// his money, and silently discarding it would understate the total.
func currency(names map[int64]string, id int64) string {
if s := names[id]; s != "" {
return s
}
return "?"
}
// sortMoney gives the amounts a stable order (largest first) so the rendered
// string and the written fact do not churn between polls.
func sortMoney(m map[string]float64) []Money {
out := make([]Money, 0, len(m))
for c, a := range m {
out = append(out, Money{Currency: c, Amount: a})
}
sort.Slice(out, func(i, j int) bool {
if out[i].Amount != out[j].Amount {
return out[i].Amount > out[j].Amount
}
return out[i].Currency < out[j].Currency
})
return out
}
// Empty reports whether the summary rests on no transactions at all. Callers
// must treat an empty summary as "nothing to say", never as a zero: the
// difference between "he spent nothing" and "the read returned nothing" is the
// difference between an answer and an invented one.
func (s Summary) Empty() bool { return s.Count == 0 }
// MonthWindow — the first instant of now's month, and now's own day-end
// exclusive bound, in now's location. The window a "сколько я потратил в этом
// месяце?" question means.
func MonthWindow(now time.Time) (from, to time.Time) {
loc := now.Location()
from = time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, loc)
to = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc).AddDate(0, 0, 1)
return from, to
}
// DayWindow — today, in now's location.
func DayWindow(now time.Time) (from, to time.Time) {
loc := now.Location()
from = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
return from, from.AddDate(0, 0, 1)
}
+178
View File
@@ -0,0 +1,178 @@
package zenmoney
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
)
// fixtureServer replays testdata/diff.json and records the request, so the
// tests can assert the wire contract (Bearer token, POST, /v8/diff/) without a
// ZenMoney account.
func fixtureServer(t *testing.T, got *diffRequest, auth *string) *httptest.Server {
t.Helper()
body, err := os.ReadFile("testdata/diff.json")
if err != nil {
t.Fatal(err)
}
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("method = %s, want POST", r.Method)
}
if r.URL.Path != "/v8/diff/" {
t.Errorf("path = %s, want /v8/diff/", r.URL.Path)
}
if auth != nil {
*auth = r.Header.Get("Authorization")
}
if got != nil {
if err := json.NewDecoder(r.Body).Decode(got); err != nil {
t.Errorf("decode request: %v", err)
}
}
w.Header().Set("Content-Type", "application/json")
w.Write(body)
}))
}
func aug(day int) time.Time { return time.Date(2026, 8, day, 0, 0, 0, 0, time.UTC) }
func TestSinceSumsSpendingPerCurrency(t *testing.T) {
var req diffRequest
var auth string
srv := fixtureServer(t, &req, &auth)
defer srv.Close()
c, err := New("tok", srv.URL, time.Second)
if err != nil {
t.Fatal(err)
}
s, err := c.Since(context.Background(), aug(1), aug(6))
if err != nil {
t.Fatal(err)
}
if auth != "Bearer tok" {
t.Errorf("Authorization = %q", auth)
}
if req.ServerTimestamp != aug(1).Unix() {
t.Errorf("serverTimestamp = %d, want the window start", req.ServerTimestamp)
}
// 1500 + 249.5 RUB spent, 12 EUR spent, 3000 RUB in. The transfer (t4), the
// deleted row (t6) and July's salary (t3) are all excluded.
want := map[string]float64{"RUB": 1749.5, "EUR": 12}
if len(s.Spent) != 2 {
t.Fatalf("spent = %+v, want two currencies", s.Spent)
}
for _, m := range s.Spent {
if want[m.Currency] != m.Amount {
t.Errorf("spent %s = %v, want %v", m.Currency, m.Amount, want[m.Currency])
}
}
if len(s.Earned) != 1 || s.Earned[0].Amount != 3000 || s.Earned[0].Currency != "RUB" {
t.Errorf("earned = %+v, want 3000 RUB (July's salary is outside the window)", s.Earned)
}
if s.Count != 4 {
t.Errorf("count = %d, want 4 counted transactions", s.Count)
}
// Largest first, so the fact value does not churn between polls.
if s.Spent[0].Currency != "RUB" {
t.Errorf("spent order = %+v, want the largest amount first", s.Spent)
}
}
// A window with nothing in it is NOT a zero. No transactions means no answer,
// and the caller must be able to tell the difference.
func TestSinceEmptyWindowIsNotAZero(t *testing.T) {
srv := fixtureServer(t, nil, nil)
defer srv.Close()
c, _ := New("tok", srv.URL, time.Second)
s, err := c.Since(context.Background(), time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC), time.Date(2026, 9, 30, 0, 0, 0, 0, time.UTC))
if err != nil {
t.Fatal(err)
}
if !s.Empty() {
t.Fatalf("summary = %+v, want empty", s)
}
if _, ok := s.Value(); ok {
t.Error("an empty summary must not produce a fact value")
}
}
func TestSinceReportsHTTPFailureWithoutTheBody(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"account":"acc-card","secret":"leaky"}`, http.StatusUnauthorized)
}))
defer srv.Close()
c, _ := New("tok", srv.URL, time.Second)
_, err := c.Since(context.Background(), aug(1), aug(6))
if err == nil {
t.Fatal("want an error on 401")
}
if strings.Contains(err.Error(), "acc-card") || strings.Contains(err.Error(), "leaky") {
t.Errorf("error %q echoes the response body — it reaches the log", err)
}
}
func TestNewRequiresAToken(t *testing.T) {
if _, err := New(" ", "", 0); err == nil {
t.Error("want an error for an empty token — the capability is off unless configured")
}
}
func TestMonthAndDayWindows(t *testing.T) {
now := time.Date(2026, 8, 15, 21, 30, 0, 0, time.UTC)
from, to := MonthWindow(now)
if from != time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC) || to != time.Date(2026, 8, 16, 0, 0, 0, 0, time.UTC) {
t.Errorf("month window = %v..%v", from, to)
}
from, to = DayWindow(now)
if from != time.Date(2026, 8, 15, 0, 0, 0, 0, time.UTC) || to != time.Date(2026, 8, 16, 0, 0, 0, 0, time.UTC) {
t.Errorf("day window = %v..%v", from, to)
}
}
func TestFactValueRoundTripAndFormat(t *testing.T) {
s := Summary{Spent: []Money{{"RUB", 1749.5}}, Earned: []Money{{"RUB", 3000}}, Count: 3}
raw, ok := s.Value()
if !ok {
t.Fatal("want a fact value")
}
v, err := ParseFactValue(raw)
if err != nil {
t.Fatal(err)
}
got := v.FormatRU("в этом месяце")
if !strings.Contains(got, "1749.5 RUB") || !strings.Contains(got, "3000 RUB") {
t.Errorf("reply = %q, want the exact figures", got)
}
// Persona: informal, feminine, no commentary on his spending.
for _, bad := range []string{"вы", "ваш", "милый", "дорогой", "рад ", "слишком", "много"} {
if strings.Contains(got, bad) {
t.Errorf("reply %q contains %q", got, bad)
}
}
if strings.Contains(got, "он ") {
t.Errorf("reply %q talks about him in the third person", got)
}
}
// An empty fact value renders to nothing, so a caller cannot accidentally
// speak a zero.
func TestFormatRUEmptyRendersNothing(t *testing.T) {
if got := (FactValue{}).FormatRU("сегодня"); got != "" {
t.Errorf("reply = %q, want empty", got)
}
}
func TestFormatAmountKeepsTheTruth(t *testing.T) {
for in, want := range map[float64]string{1500: "1500", 249.5: "249.5", 0.99: "0.99", 1749.55: "1749.55"} {
if got := formatAmount(in); got != want {
t.Errorf("formatAmount(%v) = %q, want %q", in, got, want)
}
}
}
+99
View File
@@ -0,0 +1,99 @@
package zenmoney
import (
"encoding/json"
"fmt"
"strings"
"time"
)
// Fact keys the poller writes, all under source "poll:zenmoney". Two windows,
// because they are the two questions he actually asks; a per-category
// breakdown would mean storing what he bought, and the store is not a ledger.
const (
KeySpentToday = "money_today"
KeySpentMonth = "money_month"
)
// Source — the provenance every money fact carries. The loop's rules trust
// source, and nothing in Maven has a rule on these keys: they are read when he
// asks, never a reason to speak. Maven is not a nag, least of all about money.
const Source = "poll:zenmoney"
// FactValue — the JSON stored in a money fact. A wire shape of its own rather
// than the Summary struct so From/To (which carry a timezone and a clock) stay
// out of the store; the key already says which window it is.
type FactValue struct {
Spent []Money `json:"spent"`
Earned []Money `json:"earned"`
Count int `json:"count"`
}
// Value encodes the summary for the facts table. Returns ok=false for an empty
// summary: no transactions read means no fact written, so that a failed or
// empty poll can never be recited back to him as a zero.
func (s Summary) Value() (string, bool) {
if s.Empty() {
return "", false
}
b, err := json.Marshal(FactValue{Spent: s.Spent, Earned: s.Earned, Count: s.Count})
if err != nil {
return "", false
}
return string(b), true
}
// ParseFactValue decodes a stored money fact.
func ParseFactValue(raw string) (FactValue, error) {
var v FactValue
if err := json.Unmarshal([]byte(raw), &v); err != nil {
return FactValue{}, err
}
return v, nil
}
// FormatRU renders a money fact the way Maven says it — feminine, informal,
// and only about numbers that came from ZenMoney. window is the Russian phrase
// for the period ("сегодня", "в этом месяце").
//
// No commentary. She reports the figure and stops: an opinion about his
// spending is exactly the nagging Maven is not for.
func (v FactValue) FormatRU(window string) string {
if v.Count == 0 {
return ""
}
var parts []string
if len(v.Spent) > 0 {
parts = append(parts, "потратил "+joinMoney(v.Spent))
}
if len(v.Earned) > 0 {
parts = append(parts, "получил "+joinMoney(v.Earned))
}
if len(parts) == 0 {
return ""
}
return window + " ты " + strings.Join(parts, ", ") + "."
}
func joinMoney(ms []Money) string {
parts := make([]string, 0, len(ms))
for _, m := range ms {
parts = append(parts, fmt.Sprintf("%s %s", formatAmount(m.Amount), m.Currency))
}
return strings.Join(parts, " и ")
}
// formatAmount — whole units when the amount is whole, two decimals otherwise.
// Never rounded to something prettier than the truth.
func formatAmount(a float64) string {
if a == float64(int64(a)) {
return fmt.Sprintf("%d", int64(a))
}
return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.2f", a), "0"), ".")
}
// StaleAfter — how old a money fact may be and still be worth reciting. The
// poller is off unless configured and can be down; answering with last week's
// total as if it were today's would be a lie by omission, so a stale fact is
// reported as stale.
const StaleAfter = 26 * time.Hour
+35
View File
@@ -0,0 +1,35 @@
{
"serverTimestamp": 1785312000,
"instrument": [
{"id": 2, "title": "Российский рубль", "shortTitle": "RUB", "symbol": "₽", "rate": 1},
{"id": 3, "title": "Евро", "shortTitle": "EUR", "symbol": "€", "rate": 100}
],
"account": [
{"id": "acc-card", "title": "карта", "instrument": 2},
{"id": "acc-cash", "title": "наличные", "instrument": 2},
{"id": "acc-eur", "title": "евро", "instrument": 3}
],
"transaction": [
{"id": "t1", "date": "2026-08-01", "changed": 1785300000, "income": 0, "outcome": 1500,
"incomeInstrument": 2, "outcomeInstrument": 2, "incomeAccount": "acc-card", "outcomeAccount": "acc-card",
"payee": "пятёрочка", "deleted": false},
{"id": "t2", "date": "2026-08-01", "changed": 1785300001, "income": 0, "outcome": 249.5,
"incomeInstrument": 2, "outcomeInstrument": 2, "incomeAccount": "acc-card", "outcomeAccount": "acc-card",
"payee": "метро", "deleted": false},
{"id": "t3", "date": "2026-07-20", "changed": 1785300002, "income": 120000, "outcome": 0,
"incomeInstrument": 2, "outcomeInstrument": 2, "incomeAccount": "acc-card", "outcomeAccount": "acc-card",
"payee": "зарплата", "deleted": false},
{"id": "t4", "date": "2026-08-02", "changed": 1785300003, "income": 5000, "outcome": 5000,
"incomeInstrument": 2, "outcomeInstrument": 2, "incomeAccount": "acc-cash", "outcomeAccount": "acc-card",
"payee": "", "comment": "снял наличные", "deleted": false},
{"id": "t5", "date": "2026-08-03", "changed": 1785300004, "income": 0, "outcome": 12,
"incomeInstrument": 3, "outcomeInstrument": 3, "incomeAccount": "acc-eur", "outcomeAccount": "acc-eur",
"payee": "hosting", "deleted": false},
{"id": "t6", "date": "2026-08-04", "changed": 1785300005, "income": 0, "outcome": 999,
"incomeInstrument": 2, "outcomeInstrument": 2, "incomeAccount": "acc-card", "outcomeAccount": "acc-card",
"payee": "удалённая", "deleted": true},
{"id": "t7", "date": "2026-08-05", "changed": 1785300006, "income": 3000, "outcome": 0,
"incomeInstrument": 2, "outcomeInstrument": 2, "incomeAccount": "acc-card", "outcomeAccount": "acc-card",
"payee": "возврат", "deleted": false}
]
}