zenmoney: bound the day fact to its own day and stamp when it was read
The day total rolls over at midnight and the poller had nothing to write until the first spend of the new day, so at 09:00 the latest money_today fact was yesterday's spending and looked perfectly fresh. The value now carries the first instant of the window it covers, and a today question that the stored window does not cover is refused rather than answered with yesterday's number. Staleness was measured off the fact timestamp, which only moved when the figure moved, so a quiet month was reported as data from three days ago while being current. The value now carries when it was last read and the poller writes on every read. Amounts in an instrument the window diff never named were spoken with a numeric instrument id as the currency. Instruments are resolved from one cursor-zero diff, cached for the process, and an amount still unnamed is dropped from speech rather than recited wrongly. "сколько я потратил вчера" was answered with the month total, a real number to a different question, and is now refused by naming the two windows she keeps. Income questions led with the spending. Found in review of #62.
This commit is contained in:
+45
-12
@@ -16,12 +16,29 @@ const (
|
||||
MoneyNone MoneyWindow = iota
|
||||
MoneyToday
|
||||
MoneyMonth
|
||||
// MoneyUnsupported — a money question over a window nothing is stored for
|
||||
// ("вчера", "на прошлой неделе"). Claimed, not answered: the poller keeps
|
||||
// today and the month, and answering a question about yesterday with the
|
||||
// month-to-date total is worse than saying she does not keep it.
|
||||
MoneyUnsupported
|
||||
)
|
||||
|
||||
// MoneyQuery — a parsed money question. Income is set when he asked what he
|
||||
// EARNED rather than what he spent; the two read the same fact and differ only
|
||||
// in which half of it leads the answer.
|
||||
type MoneyQuery struct {
|
||||
Window MoneyWindow
|
||||
Income bool
|
||||
}
|
||||
|
||||
// incomeNouns — the words that make a money question be about income.
|
||||
var incomeNouns = []string{"заработал", "заработала", "получил", "доход", "доходы", "earned", "income"}
|
||||
|
||||
// moneyNouns — the words that make a question be about his money.
|
||||
var moneyNouns = []string{
|
||||
"потратил", "потратила", "тратил", "траты", "трат", "расходы", "расходов",
|
||||
"заработал", "потрачено", "денег", "spend", "spent", "expenses",
|
||||
"заработал", "заработала", "доход", "доходы", "потрачено", "денег",
|
||||
"spend", "spent", "expenses", "earned", "income",
|
||||
}
|
||||
|
||||
// ParseMoneyQuery reports whether an utterance asks about spending or income,
|
||||
@@ -30,11 +47,14 @@ var moneyNouns = []string{
|
||||
//
|
||||
// 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) {
|
||||
// question word has to be there too. The two evidence halves are INDEPENDENT:
|
||||
// "траты" and "расходы" used to sit in both lists, so either word alone
|
||||
// satisfied the whole gate and "у меня в этом месяце большие траты", a
|
||||
// statement, came back with a figure.
|
||||
func ParseMoneyQuery(text string) (MoneyQuery, bool) {
|
||||
toks := planTokens(text)
|
||||
if len(toks) == 0 {
|
||||
return MoneyNone, false
|
||||
return MoneyQuery{}, false
|
||||
}
|
||||
hasNoun := false
|
||||
for _, t := range toks {
|
||||
@@ -45,27 +65,40 @@ func ParseMoneyQuery(text string) (MoneyWindow, bool) {
|
||||
}
|
||||
}
|
||||
if !hasNoun {
|
||||
return MoneyNone, false
|
||||
return MoneyQuery{}, false
|
||||
}
|
||||
// "весь день", "время", "силы" — spending that is not money.
|
||||
for _, t := range toks {
|
||||
switch t {
|
||||
case "день", "дня", "время", "времени", "силы", "сил", "нервы":
|
||||
return MoneyNone, false
|
||||
return MoneyQuery{}, false
|
||||
}
|
||||
}
|
||||
asking := hasTok(toks, "сколько") || hasTok(toks, "какие") || hasTok(toks, "покажи") ||
|
||||
hasTok(toks, "how") || hasTok(toks, "much") || hasTok(toks, "my") ||
|
||||
hasTok(toks, "мои") || hasTok(toks, "траты") || hasTok(toks, "расходы")
|
||||
hasTok(toks, "мои")
|
||||
if !asking {
|
||||
return MoneyNone, false
|
||||
return MoneyQuery{}, false
|
||||
}
|
||||
income := false
|
||||
for _, t := range toks {
|
||||
for _, n := range incomeNouns {
|
||||
if t == n {
|
||||
income = true
|
||||
}
|
||||
}
|
||||
}
|
||||
lower := strings.ToLower(text)
|
||||
switch {
|
||||
// Windows nothing is stored for, named explicitly so they are refused
|
||||
// rather than silently answered with the month.
|
||||
case hasTok(toks, "вчера") || hasTok(toks, "позавчера") || strings.Contains(lower, "yesterday"),
|
||||
hasTok(toks, "неделю") || hasTok(toks, "неделе") || hasTok(toks, "неделя") ||
|
||||
strings.Contains(lower, "week"),
|
||||
hasTok(toks, "год") || hasTok(toks, "году") || strings.Contains(lower, "year"):
|
||||
return MoneyQuery{Window: MoneyUnsupported, Income: income}, true
|
||||
case hasTok(toks, "сегодня") || strings.Contains(lower, "today"):
|
||||
return MoneyToday, true
|
||||
case hasTok(toks, "месяц") || hasTok(toks, "месяце") || strings.Contains(lower, "month"):
|
||||
return MoneyMonth, true
|
||||
return MoneyQuery{Window: MoneyToday, Income: income}, true
|
||||
}
|
||||
return MoneyMonth, true
|
||||
return MoneyQuery{Window: MoneyMonth, Income: income}, true
|
||||
}
|
||||
|
||||
@@ -15,17 +15,25 @@ func TestParseMoneyQuery(t *testing.T) {
|
||||
{"какие у меня расходы за месяц", MoneyMonth, true},
|
||||
{"how much did I spend today", MoneyToday, true},
|
||||
{"сколько я заработал в этом месяце", MoneyMonth, true},
|
||||
// Windows nothing is stored for are claimed and refused, never answered
|
||||
// with the month-to-date figure.
|
||||
{"сколько я потратил вчера?", MoneyUnsupported, true},
|
||||
{"сколько я потратил на прошлой неделе?", MoneyUnsupported, true},
|
||||
{"how much did I spend yesterday", MoneyUnsupported, true},
|
||||
// Not about money.
|
||||
{"я потратил весь день на это", MoneyNone, false},
|
||||
// A statement, not a question: the noun and the ask must be independent
|
||||
// evidence, and "траты" used to satisfy both halves on its own.
|
||||
{"у меня в этом месяце большие траты", 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)
|
||||
q, ok := ParseMoneyQuery(c.in)
|
||||
if ok != c.ok || q.Window != c.window {
|
||||
t.Errorf("ParseMoneyQuery(%q) = (%v, %v), want (%v, %v)", c.in, q.Window, ok, c.window, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+84
-11
@@ -27,6 +27,7 @@ import (
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -40,6 +41,14 @@ type Client struct {
|
||||
BaseURL string
|
||||
Token string
|
||||
HTTP *http.Client
|
||||
|
||||
// instruments — id → short title, fetched once from a cursor-zero diff and
|
||||
// kept for the process lifetime. Currencies do not change; the reason this
|
||||
// cache exists is that a windowed diff only returns objects changed since
|
||||
// the cursor, so a day window almost never carries the instrument rows the
|
||||
// transactions in it point at.
|
||||
mu sync.Mutex
|
||||
instruments map[int64]string
|
||||
}
|
||||
|
||||
// New returns a client with a bounded HTTP timeout. An empty token is a
|
||||
@@ -124,14 +133,77 @@ type Summary struct {
|
||||
// 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.
|
||||
// records cannot inflate this month's total.
|
||||
//
|
||||
// The reverse case is real and undercounts: a transaction dated inside the
|
||||
// window but last CHANGED before `from` never arrives. A planned transaction
|
||||
// entered last month and dated this month is exactly that, and it goes missing
|
||||
// from the total. Widening the cursor would mean pulling his whole history
|
||||
// every poll, so the total is "what was filed or touched in the window", and
|
||||
// that is the honest reading of it.
|
||||
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
|
||||
names := c.currencyNames(ctx, resp)
|
||||
return summarize(resp, names, from, to), nil
|
||||
}
|
||||
|
||||
// currencyNames resolves instrument ids to short titles. The window's own diff
|
||||
// first, then — only if a transaction in it points at an instrument the window
|
||||
// did not carry — one cursor-zero diff, cached for the process lifetime.
|
||||
//
|
||||
// A failed instrument fetch is not an error: the summary is still every number
|
||||
// the API returned, and an amount whose currency cannot be named is dropped
|
||||
// from the spoken string rather than read out as "1749.5 ?".
|
||||
func (c *Client) currencyNames(ctx context.Context, resp diffResponse) map[int64]string {
|
||||
names := map[int64]string{}
|
||||
for _, in := range resp.Instrument {
|
||||
names[in.ID] = in.ShortTitle
|
||||
}
|
||||
missing := false
|
||||
for _, t := range resp.Transaction {
|
||||
if t.Deleted {
|
||||
continue
|
||||
}
|
||||
for _, id := range []int64{t.OutcomeInstrmnt, t.IncomeInstrument} {
|
||||
if id != 0 && names[id] == "" {
|
||||
missing = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !missing {
|
||||
return names
|
||||
}
|
||||
for id, title := range c.allInstruments(ctx) {
|
||||
if names[id] == "" {
|
||||
names[id] = title
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// allInstruments fetches every instrument once, from a cursor-zero diff, and
|
||||
// caches it. The response also carries transactions, which are decoded and
|
||||
// dropped: this is the one call in the package that reads more of his financial
|
||||
// life than the question needs, and it happens at most once per process.
|
||||
func (c *Client) allInstruments(ctx context.Context) map[int64]string {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.instruments != nil {
|
||||
return c.instruments
|
||||
}
|
||||
resp, err := c.diff(ctx, 0)
|
||||
if err != nil {
|
||||
// Not cached: a network failure is not a fact about his currencies.
|
||||
return nil
|
||||
}
|
||||
c.instruments = make(map[int64]string, len(resp.Instrument))
|
||||
for _, in := range resp.Instrument {
|
||||
c.instruments[in.ID] = in.ShortTitle
|
||||
}
|
||||
return c.instruments
|
||||
}
|
||||
|
||||
func (c *Client) diff(ctx context.Context, serverTimestamp int64) (diffResponse, error) {
|
||||
@@ -179,11 +251,7 @@ func (c *Client) diff(ctx context.Context, serverTimestamp int64) (diffResponse,
|
||||
// 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
|
||||
}
|
||||
func summarize(resp diffResponse, cur map[int64]string, from, to time.Time) Summary {
|
||||
spent := map[string]float64{}
|
||||
earned := map[string]float64{}
|
||||
count := 0
|
||||
@@ -217,14 +285,19 @@ func summarize(resp diffResponse, from, to time.Time) Summary {
|
||||
}
|
||||
}
|
||||
|
||||
// UnknownCurrency — the label for an instrument id nothing could name. It
|
||||
// survives into the Summary so a caller can see that a bucket exists; the
|
||||
// renderer drops it rather than reading "?" aloud as a currency.
|
||||
const UnknownCurrency = "?"
|
||||
|
||||
// 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.
|
||||
// its bucket in the Summary rather than being folded into a named one: a sum is
|
||||
// only checkable against his bank if every amount in it is in one currency.
|
||||
func currency(names map[int64]string, id int64) string {
|
||||
if s := names[id]; s != "" {
|
||||
return s
|
||||
}
|
||||
return "?"
|
||||
return UnknownCurrency
|
||||
}
|
||||
|
||||
// sortMoney gives the amounts a stable order (largest first) so the rendered
|
||||
|
||||
@@ -98,7 +98,7 @@ func TestSinceEmptyWindowIsNotAZero(t *testing.T) {
|
||||
if !s.Empty() {
|
||||
t.Fatalf("summary = %+v, want empty", s)
|
||||
}
|
||||
if _, ok := s.Value(); ok {
|
||||
if _, ok := s.Value(time.Now()); ok {
|
||||
t.Error("an empty summary must not produce a fact value")
|
||||
}
|
||||
}
|
||||
@@ -138,7 +138,7 @@ func TestMonthAndDayWindows(t *testing.T) {
|
||||
|
||||
func TestFactValueRoundTripAndFormat(t *testing.T) {
|
||||
s := Summary{Spent: []Money{{"RUB", 1749.5}}, Earned: []Money{{"RUB", 3000}}, Count: 3}
|
||||
raw, ok := s.Value()
|
||||
raw, ok := s.Value(time.Date(2026, 8, 1, 22, 0, 0, 0, time.UTC))
|
||||
if !ok {
|
||||
t.Fatal("want a fact value")
|
||||
}
|
||||
@@ -176,3 +176,99 @@ func TestFormatAmountKeepsTheTruth(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The day window rolls over at midnight and the first spend of the new day may
|
||||
// be hours away, so the last good money_today fact keeps a fresh ts while
|
||||
// covering yesterday. Only the window stamp inside the value can tell.
|
||||
func TestFactValueCoversDay(t *testing.T) {
|
||||
from, _ := DayWindow(time.Date(2026, 8, 1, 22, 0, 0, 0, time.UTC))
|
||||
s := Summary{From: from, Spent: []Money{{"RUB", 1749.5}}, Count: 1}
|
||||
raw, ok := s.Value(time.Date(2026, 8, 1, 22, 0, 0, 0, time.UTC))
|
||||
if !ok {
|
||||
t.Fatal("want a fact value")
|
||||
}
|
||||
v, err := ParseFactValue(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !v.CoversDay(time.Date(2026, 8, 1, 23, 59, 0, 0, time.UTC)) {
|
||||
t.Error("the same day must be covered")
|
||||
}
|
||||
if v.CoversDay(time.Date(2026, 8, 2, 9, 0, 0, 0, time.UTC)) {
|
||||
t.Error("yesterday's day total must not count as today's")
|
||||
}
|
||||
if (FactValue{}).CoversDay(time.Date(2026, 8, 2, 9, 0, 0, 0, time.UTC)) {
|
||||
t.Error("a value with no window stamp must fail closed")
|
||||
}
|
||||
if v.AsOf.IsZero() {
|
||||
t.Error("the value must carry when it was read, not only when it changed")
|
||||
}
|
||||
}
|
||||
|
||||
// The instrument rows are only in the diff when they changed since the cursor,
|
||||
// which for a day window they usually have not. An amount whose currency
|
||||
// nothing could name is dropped from the spoken string rather than read out
|
||||
// as "1749.5 ?".
|
||||
func TestUnknownCurrencyIsNotSpoken(t *testing.T) {
|
||||
v := FactValue{Spent: []Money{{UnknownCurrency, 1749.5}}, Count: 1}
|
||||
if got := v.FormatRU("сегодня"); got != "" {
|
||||
t.Errorf("reply = %q, want nothing said about an unlabelled amount", got)
|
||||
}
|
||||
v = FactValue{Spent: []Money{{"RUB", 100}, {UnknownCurrency, 1749.5}}, Count: 2}
|
||||
got := v.FormatRU("сегодня")
|
||||
if strings.Contains(got, UnknownCurrency) {
|
||||
t.Errorf("reply = %q, want no %q currency", got, UnknownCurrency)
|
||||
}
|
||||
if !strings.Contains(got, "100 RUB") {
|
||||
t.Errorf("reply = %q, want the amount that does have a currency", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A day diff cursored at midnight usually carries no instrument rows at all.
|
||||
// The client fetches them once from a cursor-zero diff instead of labelling
|
||||
// every amount "?".
|
||||
func TestSinceResolvesCurrencyFromASeparateDiff(t *testing.T) {
|
||||
body, err := os.ReadFile("testdata/diff.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var full diffResponse
|
||||
if err := json.Unmarshal(body, &full); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
windowed := diffResponse{ServerTimestamp: full.ServerTimestamp, Transaction: full.Transaction}
|
||||
instrumentsOnly := diffResponse{ServerTimestamp: full.ServerTimestamp, Instrument: full.Instrument}
|
||||
zeroCursorCalls := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req diffRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Errorf("decode request: %v", err)
|
||||
}
|
||||
out := windowed
|
||||
if req.ServerTimestamp == 0 {
|
||||
zeroCursorCalls++
|
||||
out = instrumentsOnly
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(out)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c, _ := New("tok", srv.URL, time.Second)
|
||||
s, err := c.Since(context.Background(), aug(1), aug(6))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, m := range s.Spent {
|
||||
if m.Currency == UnknownCurrency {
|
||||
t.Fatalf("spent = %+v, want every amount named", s.Spent)
|
||||
}
|
||||
}
|
||||
// Cached for the process lifetime: a second window does not refetch.
|
||||
if _, err := c.Since(context.Background(), aug(1), aug(6)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if zeroCursorCalls != 1 {
|
||||
t.Errorf("cursor-zero diffs = %d, want exactly 1", zeroCursorCalls)
|
||||
}
|
||||
}
|
||||
|
||||
+80
-14
@@ -20,29 +20,61 @@ const (
|
||||
// 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.
|
||||
// FactValue — the JSON stored in a money fact.
|
||||
//
|
||||
// From and AsOf are both here because the fact's own Ts can express neither.
|
||||
//
|
||||
// - From is the first instant of the window the figure covers. The day fact
|
||||
// is only true for the day it was read on, and after midnight the poller
|
||||
// has nothing new to write until the first spend of the new day, so the
|
||||
// previous day's total sits there as the latest money_today looking
|
||||
// perfectly fresh. Without From, "сколько я потратил сегодня?" at 09:00
|
||||
// answered with yesterday's spending.
|
||||
// - AsOf is when the figure was last READ, not when it last changed. The
|
||||
// poller used to skip a write when the value was byte-identical, so a quiet
|
||||
// stretch left Ts pointing at the last time the number moved and the answer
|
||||
// came back prefixed "данные от 30.07" while being current and correct.
|
||||
type FactValue struct {
|
||||
Spent []Money `json:"spent"`
|
||||
Earned []Money `json:"earned"`
|
||||
Count int `json:"count"`
|
||||
Spent []Money `json:"spent"`
|
||||
Earned []Money `json:"earned"`
|
||||
Count int `json:"count"`
|
||||
From time.Time `json:"from,omitempty"`
|
||||
AsOf time.Time `json:"as_of,omitempty"`
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// Value encodes the summary for the facts table, stamped with the instant it
|
||||
// was read. 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(asOf time.Time) (string, bool) {
|
||||
if s.Empty() {
|
||||
return "", false
|
||||
}
|
||||
b, err := json.Marshal(FactValue{Spent: s.Spent, Earned: s.Earned, Count: s.Count})
|
||||
b, err := json.Marshal(FactValue{
|
||||
Spent: s.Spent, Earned: s.Earned, Count: s.Count,
|
||||
From: s.From, AsOf: asOf,
|
||||
})
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return string(b), true
|
||||
}
|
||||
|
||||
// CoversDay reports whether this value's window starts at now's midnight, in
|
||||
// now's location. A day total whose window has rolled over is not a stale
|
||||
// figure to be prefixed with a date, it is an answer to a different question,
|
||||
// and it must not be spoken as today's.
|
||||
//
|
||||
// A value written before From existed has a zero From and fails the check,
|
||||
// which is the safe direction: the next poll rewrites it.
|
||||
func (v FactValue) CoversDay(now time.Time) bool {
|
||||
if v.From.IsZero() {
|
||||
return false
|
||||
}
|
||||
f := v.From.In(now.Location())
|
||||
return f.Year() == now.Year() && f.Month() == now.Month() && f.Day() == now.Day()
|
||||
}
|
||||
|
||||
// ParseFactValue decodes a stored money fact.
|
||||
func ParseFactValue(raw string) (FactValue, error) {
|
||||
var v FactValue
|
||||
@@ -59,15 +91,41 @@ func ParseFactValue(raw string) (FactValue, error) {
|
||||
// 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 {
|
||||
return v.formatRU(window, false)
|
||||
}
|
||||
|
||||
// FormatIncomeRU is FormatRU with the income read first, for a question that
|
||||
// asked about income ("сколько я заработал в этом месяце?"). Same figures, same
|
||||
// refusal to comment; only the order of the two halves differs, so the number
|
||||
// he asked for is the number she says first.
|
||||
func (v FactValue) FormatIncomeRU(window string) string {
|
||||
return v.formatRU(window, true)
|
||||
}
|
||||
|
||||
func (v FactValue) formatRU(window string, incomeFirst bool) string {
|
||||
if v.Count == 0 {
|
||||
return ""
|
||||
}
|
||||
var parts []string
|
||||
spent, earned := "", ""
|
||||
if len(v.Spent) > 0 {
|
||||
parts = append(parts, "потратил "+joinMoney(v.Spent))
|
||||
if s := joinMoney(v.Spent); s != "" {
|
||||
spent = "потратил " + s
|
||||
}
|
||||
}
|
||||
if len(v.Earned) > 0 {
|
||||
parts = append(parts, "получил "+joinMoney(v.Earned))
|
||||
if s := joinMoney(v.Earned); s != "" {
|
||||
earned = "получил " + s
|
||||
}
|
||||
}
|
||||
order := []string{spent, earned}
|
||||
if incomeFirst {
|
||||
order = []string{earned, spent}
|
||||
}
|
||||
var parts []string
|
||||
for _, p := range order {
|
||||
if p != "" {
|
||||
parts = append(parts, p)
|
||||
}
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
@@ -75,9 +133,17 @@ func (v FactValue) FormatRU(window string) string {
|
||||
return window + " ты " + strings.Join(parts, ", ") + "."
|
||||
}
|
||||
|
||||
// joinMoney renders the amounts, DROPPING any whose currency could not be
|
||||
// named. "сегодня ты потратил 1749.5 ?." is not something to read aloud, and a
|
||||
// figure with no currency on it is not a figure he can check against his bank.
|
||||
// An amount silently missing is the lesser wrong: the alternative is speaking a
|
||||
// number whose units Maven does not know.
|
||||
func joinMoney(ms []Money) string {
|
||||
parts := make([]string, 0, len(ms))
|
||||
for _, m := range ms {
|
||||
if m.Currency == UnknownCurrency || m.Currency == "" {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("%s %s", formatAmount(m.Amount), m.Currency))
|
||||
}
|
||||
return strings.Join(parts, " и ")
|
||||
|
||||
Reference in New Issue
Block a user