feat: Wave 10 — expense tracker pack (voice-driven)
resources/commands/expenses/ (4 commands):
- expenses.log "потратил 500 на еду" → append entry
- expenses.today "сколько потратил сегодня" → today's total
- expenses.week "сколько на неделе" → 7-day rolling total
- expenses.breakdown "куда ушли деньги" → top-5 categories
Storage: `jarvis.state` (auto-namespaced per-pack JSON). Wire format is
compact "ts1,amount1,cat1|ts2,amount2,cat2" so we don't need a JSON parser
inside Lua.
Phrase parsing:
- Trigger stripped via jarvis.text.strip_trigger.
- First number captured; "12,50" comma decimals accepted.
- Category extracted from "на <слово>" (RU) or "for/on <word>" (EN);
defaults to "разное" / "misc".
Time math:
- Day boundary: local-calendar midnight via os.time({year,month,day,...}).
- Week boundary: rolling 7×86400 seconds.
Pack count: 98 → 99 functional.
This commit is contained in:
parent
bb3b4f5942
commit
87bb186e94
5 changed files with 293 additions and 0 deletions
55
resources/commands/expenses/breakdown.lua
Normal file
55
resources/commands/expenses/breakdown.lua
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
-- Per-category breakdown for the last 7 days, sorted by amount desc.
|
||||||
|
|
||||||
|
local lang = jarvis.context.language
|
||||||
|
local raw_state = jarvis.state.get("entries") or ""
|
||||||
|
if raw_state == "" then
|
||||||
|
return jarvis.cmd.not_found(lang == "ru"
|
||||||
|
and "Нечего разбивать, сэр."
|
||||||
|
or "Nothing to break down, sir.")
|
||||||
|
end
|
||||||
|
|
||||||
|
local now = jarvis.context.time
|
||||||
|
local now_ts = os.time({
|
||||||
|
year = now.year, month = now.month, day = now.day,
|
||||||
|
hour = now.hour, min = now.minute, sec = now.second or 0,
|
||||||
|
})
|
||||||
|
local cutoff = now_ts - 7 * 86400
|
||||||
|
|
||||||
|
local by_cat = {}
|
||||||
|
for chunk in raw_state:gmatch("([^|]+)") do
|
||||||
|
local ts, am, ca = chunk:match("^(%d+),([%-%d%.]+),(.+)$")
|
||||||
|
ts = tonumber(ts); am = tonumber(am)
|
||||||
|
if ts and am and ts >= cutoff then
|
||||||
|
by_cat[ca] = (by_cat[ca] or 0) + am
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local rows = {}
|
||||||
|
for ca, total in pairs(by_cat) do
|
||||||
|
table.insert(rows, { ca = ca, total = total })
|
||||||
|
end
|
||||||
|
table.sort(rows, function(a, b) return a.total > b.total end)
|
||||||
|
|
||||||
|
if #rows == 0 then
|
||||||
|
return jarvis.cmd.ok(lang == "ru"
|
||||||
|
and "На этой неделе тишина по тратам, сэр."
|
||||||
|
or "Quiet week on spending, sir.")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Top 5 categories spoken; rest collapses into "и другое"
|
||||||
|
local top = math.min(5, #rows)
|
||||||
|
local parts = {}
|
||||||
|
for i = 1, top do
|
||||||
|
table.insert(parts, string.format("%s %.0f", rows[i].ca, rows[i].total))
|
||||||
|
end
|
||||||
|
local tail = ""
|
||||||
|
if #rows > top then
|
||||||
|
local rest_total = 0
|
||||||
|
for i = top + 1, #rows do rest_total = rest_total + rows[i].total end
|
||||||
|
tail = string.format(lang == "ru"
|
||||||
|
and " и другое %.0f" or " and other %.0f", rest_total)
|
||||||
|
end
|
||||||
|
|
||||||
|
return jarvis.cmd.ok(string.format(lang == "ru"
|
||||||
|
and "По категориям: %s%s."
|
||||||
|
or "By category: %s%s.", table.concat(parts, ", "), tail))
|
||||||
91
resources/commands/expenses/command.toml
Normal file
91
resources/commands/expenses/command.toml
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
# Personal-finance tracker — voice-log expenses by category, get totals.
|
||||||
|
# Storage is per-pack via `jarvis.state` (auto-namespaced JSON), so it doesn't
|
||||||
|
# bleed into other packs' data.
|
||||||
|
#
|
||||||
|
# Voice flow:
|
||||||
|
# "потратил 500 на еду" → log entry (amount=500, category="еда")
|
||||||
|
# "сколько я потратил сегодня" → speak today's total
|
||||||
|
# "сколько на этой неделе" → speak 7-day total
|
||||||
|
# "куда ушли деньги" → speak per-category breakdown for the week
|
||||||
|
|
||||||
|
[[commands]]
|
||||||
|
id = "expenses.log"
|
||||||
|
type = "lua"
|
||||||
|
script = "log.lua"
|
||||||
|
sandbox = "standard"
|
||||||
|
timeout = 3000
|
||||||
|
|
||||||
|
[commands.phrases]
|
||||||
|
ru = [
|
||||||
|
"потратил",
|
||||||
|
"записать трату",
|
||||||
|
"запиши трату",
|
||||||
|
"купил за",
|
||||||
|
"заплатил за",
|
||||||
|
]
|
||||||
|
en = [
|
||||||
|
"spent",
|
||||||
|
"log expense",
|
||||||
|
"paid for",
|
||||||
|
"bought",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
[[commands]]
|
||||||
|
id = "expenses.today"
|
||||||
|
type = "lua"
|
||||||
|
script = "today.lua"
|
||||||
|
sandbox = "standard"
|
||||||
|
timeout = 3000
|
||||||
|
|
||||||
|
[commands.phrases]
|
||||||
|
ru = [
|
||||||
|
"сколько я потратил сегодня",
|
||||||
|
"траты сегодня",
|
||||||
|
"сколько ушло сегодня",
|
||||||
|
]
|
||||||
|
en = [
|
||||||
|
"spending today",
|
||||||
|
"expenses today",
|
||||||
|
"how much spent today",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
[[commands]]
|
||||||
|
id = "expenses.week"
|
||||||
|
type = "lua"
|
||||||
|
script = "week.lua"
|
||||||
|
sandbox = "standard"
|
||||||
|
timeout = 3000
|
||||||
|
|
||||||
|
[commands.phrases]
|
||||||
|
ru = [
|
||||||
|
"сколько я потратил на этой неделе",
|
||||||
|
"траты на неделе",
|
||||||
|
"недельные траты",
|
||||||
|
]
|
||||||
|
en = [
|
||||||
|
"spending this week",
|
||||||
|
"weekly expenses",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
[[commands]]
|
||||||
|
id = "expenses.breakdown"
|
||||||
|
type = "lua"
|
||||||
|
script = "breakdown.lua"
|
||||||
|
sandbox = "standard"
|
||||||
|
timeout = 3000
|
||||||
|
|
||||||
|
[commands.phrases]
|
||||||
|
ru = [
|
||||||
|
"куда ушли деньги",
|
||||||
|
"разбивка по тратам",
|
||||||
|
"на что я тратил",
|
||||||
|
"разбивка по категориям",
|
||||||
|
]
|
||||||
|
en = [
|
||||||
|
"where did the money go",
|
||||||
|
"expense breakdown",
|
||||||
|
"spending by category",
|
||||||
|
]
|
||||||
74
resources/commands/expenses/log.lua
Normal file
74
resources/commands/expenses/log.lua
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
-- Voice "потратил 500 на еду" → append {amount, category, timestamp} to the
|
||||||
|
-- pack's state JSON.
|
||||||
|
--
|
||||||
|
-- Parsing the phrase:
|
||||||
|
-- 1. Strip the trigger ("потратил" / "spent" / ...)
|
||||||
|
-- 2. First number = amount
|
||||||
|
-- 3. After the number: optional "на <слово>" → category (RU) or "for <word>"
|
||||||
|
-- / "on <word>" (EN). Otherwise category defaults to "разное" / "misc".
|
||||||
|
|
||||||
|
local lang = jarvis.context.language
|
||||||
|
local raw = (jarvis.context.phrase or ""):lower()
|
||||||
|
|
||||||
|
local triggers = {
|
||||||
|
"потратил", "записать трату", "запиши трату", "купил за", "заплатил за",
|
||||||
|
"spent", "log expense", "paid for", "bought",
|
||||||
|
}
|
||||||
|
local rest = jarvis.text.strip_trigger(raw, triggers) or raw
|
||||||
|
rest = rest:gsub("^%s+", ""):gsub("%s+$", "")
|
||||||
|
|
||||||
|
-- Pull the first integer-like token. Allows comma decimals ("12,50" → 12.5).
|
||||||
|
local amount_str, after = rest:match("(%-?[%d]+[%.,]?[%d]*)%s*(.*)")
|
||||||
|
if not amount_str then
|
||||||
|
return jarvis.cmd.not_found(lang == "ru"
|
||||||
|
and "Не понял сумму, сэр."
|
||||||
|
or "I didn't catch the amount, sir.")
|
||||||
|
end
|
||||||
|
amount_str = amount_str:gsub(",", ".")
|
||||||
|
local amount = tonumber(amount_str)
|
||||||
|
if not amount or amount <= 0 then
|
||||||
|
return jarvis.cmd.not_found(lang == "ru"
|
||||||
|
and "Сумма не похожа на число."
|
||||||
|
or "That doesn't look like an amount.")
|
||||||
|
end
|
||||||
|
|
||||||
|
local category = "разное"
|
||||||
|
local cat_match = (after or ""):match("на%s+(%S+)") or (after or ""):match("for%s+(%S+)")
|
||||||
|
or (after or ""):match("on%s+(%S+)")
|
||||||
|
if cat_match and cat_match ~= "" then
|
||||||
|
category = cat_match
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Load existing log, append, save.
|
||||||
|
local raw_state = jarvis.state.get("entries") or ""
|
||||||
|
local entries = {}
|
||||||
|
if raw_state ~= "" then
|
||||||
|
-- Encoded as "ts1,amount1,cat1|ts2,amount2,cat2|..."
|
||||||
|
for chunk in raw_state:gmatch("([^|]+)") do
|
||||||
|
local ts, am, ca = chunk:match("^(%d+),([%-%d%.]+),(.+)$")
|
||||||
|
if ts and am and ca then
|
||||||
|
table.insert(entries, { ts = tonumber(ts), am = tonumber(am), ca = ca })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local now = jarvis.context.time
|
||||||
|
-- chrono::Local timestamp is exposed via {year, month, day, hour, minute, second}
|
||||||
|
-- but not as a unix epoch. Approximate via os.time({...}) — sandbox standard
|
||||||
|
-- gives us os.time/date.
|
||||||
|
local ts = os.time({
|
||||||
|
year = now.year, month = now.month, day = now.day,
|
||||||
|
hour = now.hour, min = now.minute, sec = now.second or 0,
|
||||||
|
})
|
||||||
|
table.insert(entries, { ts = ts, am = amount, ca = category })
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
for _, e in ipairs(entries) do
|
||||||
|
table.insert(out, string.format("%d,%s,%s", e.ts, tostring(e.am), e.ca))
|
||||||
|
end
|
||||||
|
jarvis.state.set("entries", table.concat(out, "|"))
|
||||||
|
|
||||||
|
return jarvis.cmd.ok(string.format(lang == "ru"
|
||||||
|
and "Записал: %s на %s."
|
||||||
|
or "Logged: %s on %s.",
|
||||||
|
amount_str, category))
|
||||||
36
resources/commands/expenses/today.lua
Normal file
36
resources/commands/expenses/today.lua
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
-- Sum of today's expenses (local calendar day).
|
||||||
|
|
||||||
|
local lang = jarvis.context.language
|
||||||
|
local raw_state = jarvis.state.get("entries") or ""
|
||||||
|
|
||||||
|
if raw_state == "" then
|
||||||
|
return jarvis.cmd.not_found(lang == "ru"
|
||||||
|
and "Сегодня ничего не тратили, сэр."
|
||||||
|
or "No expenses logged today, sir.")
|
||||||
|
end
|
||||||
|
|
||||||
|
local now = jarvis.context.time
|
||||||
|
local day_start = os.time({
|
||||||
|
year = now.year, month = now.month, day = now.day,
|
||||||
|
hour = 0, min = 0, sec = 0,
|
||||||
|
})
|
||||||
|
local day_end = day_start + 86400
|
||||||
|
|
||||||
|
local total = 0
|
||||||
|
for chunk in raw_state:gmatch("([^|]+)") do
|
||||||
|
local ts, am = chunk:match("^(%d+),([%-%d%.]+),")
|
||||||
|
ts = tonumber(ts); am = tonumber(am)
|
||||||
|
if ts and am and ts >= day_start and ts < day_end then
|
||||||
|
total = total + am
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if total == 0 then
|
||||||
|
return jarvis.cmd.ok(lang == "ru"
|
||||||
|
and "Сегодня ничего не тратили, сэр."
|
||||||
|
or "Nothing spent today, sir.")
|
||||||
|
end
|
||||||
|
|
||||||
|
return jarvis.cmd.ok(string.format(lang == "ru"
|
||||||
|
and "Сегодня потратили %.0f."
|
||||||
|
or "Today's spending: %.0f.", total))
|
||||||
37
resources/commands/expenses/week.lua
Normal file
37
resources/commands/expenses/week.lua
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
-- Sum of the last 7 days' expenses (rolling window).
|
||||||
|
|
||||||
|
local lang = jarvis.context.language
|
||||||
|
local raw_state = jarvis.state.get("entries") or ""
|
||||||
|
if raw_state == "" then
|
||||||
|
return jarvis.cmd.not_found(lang == "ru"
|
||||||
|
and "На этой неделе ничего не тратили."
|
||||||
|
or "Nothing logged this week.")
|
||||||
|
end
|
||||||
|
|
||||||
|
local now = jarvis.context.time
|
||||||
|
local now_ts = os.time({
|
||||||
|
year = now.year, month = now.month, day = now.day,
|
||||||
|
hour = now.hour, min = now.minute, sec = now.second or 0,
|
||||||
|
})
|
||||||
|
local cutoff = now_ts - 7 * 86400
|
||||||
|
|
||||||
|
local total = 0
|
||||||
|
local count = 0
|
||||||
|
for chunk in raw_state:gmatch("([^|]+)") do
|
||||||
|
local ts, am = chunk:match("^(%d+),([%-%d%.]+),")
|
||||||
|
ts = tonumber(ts); am = tonumber(am)
|
||||||
|
if ts and am and ts >= cutoff then
|
||||||
|
total = total + am
|
||||||
|
count = count + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if total == 0 then
|
||||||
|
return jarvis.cmd.ok(lang == "ru"
|
||||||
|
and "На этой неделе тратами не похваляешься, сэр."
|
||||||
|
or "Nothing tracked this week, sir.")
|
||||||
|
end
|
||||||
|
|
||||||
|
return jarvis.cmd.ok(string.format(lang == "ru"
|
||||||
|
and "За неделю %d трат на сумму %.0f."
|
||||||
|
or "This week: %d expenses totalling %.0f.", count, total))
|
||||||
Loading…
Add table
Add a link
Reference in a new issue