6316354518
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.
166 lines
5.8 KiB
Go
166 lines
5.8 KiB
Go
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.
|
|
//
|
|
// 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"`
|
|
From time.Time `json:"from,omitempty"`
|
|
AsOf time.Time `json:"as_of,omitempty"`
|
|
}
|
|
|
|
// 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,
|
|
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
|
|
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 {
|
|
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 ""
|
|
}
|
|
spent, earned := "", ""
|
|
if len(v.Spent) > 0 {
|
|
if s := joinMoney(v.Spent); s != "" {
|
|
spent = "потратил " + s
|
|
}
|
|
}
|
|
if len(v.Earned) > 0 {
|
|
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 ""
|
|
}
|
|
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, " и ")
|
|
}
|
|
|
|
// 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
|