diff --git a/.gitignore b/.gitignore index 065a20f..a1f13f1 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ llm_backend.txt llm_history.json active_profile.txt time_tracker.json +expenses.json # C extensions *.so diff --git a/commands.yaml b/commands.yaml index d6c726a..96b971c 100644 --- a/commands.yaml +++ b/commands.yaml @@ -1709,3 +1709,34 @@ routines_coffee_break: - перерыв на чай - отойду минут на пять action: {type: coffee_break} + +expenses_log: + phrases: + - потратил + - записать трату + - запиши трату + - купил за + - заплатил за + action: {type: expenses_log} + +expenses_today: + phrases: + - сколько я потратил сегодня + - траты сегодня + - сколько ушло сегодня + action: {type: expenses_today} + +expenses_week: + phrases: + - сколько я потратил на этой неделе + - траты на неделе + - недельные траты + action: {type: expenses_week} + +expenses_breakdown: + phrases: + - куда ушли деньги + - разбивка по тратам + - на что я тратил + - разбивка по категориям + action: {type: expenses_breakdown} diff --git a/expenses_handlers.py b/expenses_handlers.py new file mode 100644 index 0000000..81612e9 --- /dev/null +++ b/expenses_handlers.py @@ -0,0 +1,177 @@ +"""Personal finance tracker — Python parity for the Rust `expenses/` pack. + +Stores entries in `/expenses.json` (atomic write-through), exposes +the same four voice entry points as the Rust pack: + + do_expenses_log "потратил 500 на еду" + do_expenses_today "сколько потратил сегодня" + do_expenses_week "сколько на неделе" + do_expenses_breakdown "куда ушли деньги" + +State shape: { "entries": [{"ts": int, "amount": float, "category": str}, ...] } +""" + +import json +import os +import re +import time as _time +from typing import Callable, Optional + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_PATH = os.path.join(_HERE, 'expenses.json') + +_speak: Optional[Callable[[str], None]] = None + + +def set_speak(fn: Callable[[str], None]) -> None: + global _speak + _speak = fn + + +def _say(text: str) -> None: + if _speak is not None: + _speak(text) + else: + print(f"[expenses] {text}") + + +def load() -> dict: + """Return current state. Empty-shape dict if file missing or unreadable.""" + if not os.path.isfile(_PATH): + return {'entries': []} + try: + with open(_PATH, encoding='utf-8') as f: + data = json.load(f) + if not isinstance(data, dict) or not isinstance(data.get('entries'), list): + return {'entries': []} + return data + except (OSError, json.JSONDecodeError): + return {'entries': []} + + +def _save(data: dict) -> None: + tmp = _PATH + '.tmp' + try: + with open(tmp, 'w', encoding='utf-8') as f: + json.dump(data, f, ensure_ascii=False, indent=2) + os.replace(tmp, _PATH) + except OSError as e: + print(f"[expenses] save failed: {e}") + + +# Pulled out so tests can exercise parsing without going through TTS. +_TRIGGERS = [ + "потратил", "записать трату", "запиши трату", "купил за", "заплатил за", + "spent", "log expense", "paid for", "bought", +] + + +def _strip_trigger(phrase: str) -> str: + p = (phrase or "").lower().strip() + for t in _TRIGGERS: + if p.startswith(t): + return p[len(t):].strip() + return p + + +def parse_expense(phrase: str): + """Return (amount: float, category: str) or None if the phrase doesn't + contain a parseable expense. Pure function — used by both `do_expenses_log` + and the test suite.""" + rest = _strip_trigger(phrase) + m = re.match(r"(-?\d+[.,]?\d*)\s*(.*)", rest) + if not m: + return None + amount_str = m.group(1).replace(",", ".") + try: + amount = float(amount_str) + except ValueError: + return None + if amount <= 0: + return None + after = m.group(2) or "" + category = "разное" + # "на X" (RU) / "for X" / "on X" (EN) + cat_match = re.search(r"\bна\s+(\S+)", after) \ + or re.search(r"\bfor\s+(\S+)", after) \ + or re.search(r"\bon\s+(\S+)", after) + if cat_match: + category = cat_match.group(1).strip(".,!?") + return amount, category + + +def do_expenses_log(action=None, voice=None) -> None: + parsed = parse_expense(voice or "") + if parsed is None: + _say("Не понял сумму, сэр.") + return + amount, category = parsed + data = load() + data['entries'].append({ + 'ts': int(_time.time()), + 'amount': amount, + 'category': category, + }) + _save(data) + _say(f"Записал: {amount:g} на {category}.") + + +def _today_bounds() -> tuple[int, int]: + now = _time.localtime() + start = _time.mktime((now.tm_year, now.tm_mon, now.tm_mday, + 0, 0, 0, 0, 0, -1)) + return int(start), int(start) + 86400 + + +def do_expenses_today(action=None, voice=None) -> None: + data = load() + if not data['entries']: + _say("Сегодня ничего не тратили, сэр.") + return + start, end = _today_bounds() + total = sum(e['amount'] for e in data['entries'] + if isinstance(e.get('ts'), int) and start <= e['ts'] < end) + if total == 0: + _say("Сегодня ничего не тратили, сэр.") + else: + _say(f"Сегодня потратили {total:g}.") + + +def do_expenses_week(action=None, voice=None) -> None: + data = load() + if not data['entries']: + _say("На этой неделе ничего не тратили.") + return + cutoff = int(_time.time()) - 7 * 86400 + rows = [e for e in data['entries'] + if isinstance(e.get('ts'), int) and e['ts'] >= cutoff] + if not rows: + _say("На этой неделе тратами не похваляешься, сэр.") + return + total = sum(e['amount'] for e in rows) + _say(f"За неделю {len(rows)} трат на сумму {total:g}.") + + +def do_expenses_breakdown(action=None, voice=None) -> None: + data = load() + if not data['entries']: + _say("Нечего разбивать, сэр.") + return + cutoff = int(_time.time()) - 7 * 86400 + by_cat: dict[str, float] = {} + for e in data['entries']: + if isinstance(e.get('ts'), int) and e['ts'] >= cutoff: + cat = e.get('category', 'разное') + by_cat[cat] = by_cat.get(cat, 0.0) + e['amount'] + if not by_cat: + _say("На этой неделе тишина по тратам, сэр.") + return + rows = sorted(by_cat.items(), key=lambda kv: kv[1], reverse=True) + top = rows[:5] + rest = rows[5:] + parts = [f"{cat} {amt:g}" for cat, amt in top] + tail = "" + if rest: + rest_total = sum(amt for _, amt in rest) + tail = f" и другое {rest_total:g}" + _say(f"По категориям: {', '.join(parts)}{tail}.") diff --git a/extensions.py b/extensions.py index cab9432..e79b9fe 100644 --- a/extensions.py +++ b/extensions.py @@ -32,6 +32,7 @@ import personality_handlers import time_tracker_handlers import outlook_handlers import wave68_handlers +import expenses_handlers _speak_fn = print _set_clipboard_fn = None @@ -49,6 +50,7 @@ def set_speak(fn): time_tracker_handlers.set_speak(fn) outlook_handlers.set_speak(fn) wave68_handlers.set_speak(fn) + expenses_handlers.set_speak(fn) def set_clipboard_fn(fn): @@ -1166,3 +1168,11 @@ def do_trivia(a, v): wave68_handlers.do_trivia(a, v) def do_good_night(a, v): wave68_handlers.do_good_night(a, v) def do_good_morning(a, v): wave68_handlers.do_good_morning(a, v) def do_coffee_break(a, v): wave68_handlers.do_coffee_break(a, v) + + +# ── Expenses proxies (to expenses_handlers) ──────────────────────────────── + +def do_expenses_log(a, v): expenses_handlers.do_expenses_log(a, v) +def do_expenses_today(a, v): expenses_handlers.do_expenses_today(a, v) +def do_expenses_week(a, v): expenses_handlers.do_expenses_week(a, v) +def do_expenses_breakdown(a, v): expenses_handlers.do_expenses_breakdown(a, v) diff --git a/main.py b/main.py index 6228ae5..a05c07e 100644 --- a/main.py +++ b/main.py @@ -1735,6 +1735,14 @@ def run_action(action): extensions.do_good_morning(action, _current_voice or '') elif t == 'coffee_break': extensions.do_coffee_break(action, _current_voice or '') + elif t == 'expenses_log': + extensions.do_expenses_log(action, _current_voice or '') + elif t == 'expenses_today': + extensions.do_expenses_today(action, _current_voice or '') + elif t == 'expenses_week': + extensions.do_expenses_week(action, _current_voice or '') + elif t == 'expenses_breakdown': + extensions.do_expenses_breakdown(action, _current_voice or '') _current_voice: str = '' diff --git a/tests/test_expenses_handlers.py b/tests/test_expenses_handlers.py new file mode 100644 index 0000000..f752724 --- /dev/null +++ b/tests/test_expenses_handlers.py @@ -0,0 +1,116 @@ +"""Tests for `expenses_handlers.py` — voice-driven personal finance tracker.""" + +import os +import sys +import time as _time +import pytest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + + +def _capture(eh): + captured = [] + eh.set_speak(captured.append) + return captured + + +def test_parse_expense_extracts_amount_and_category(isolated_state): + eh = isolated_state('expenses_handlers', ['_PATH']) + assert eh.parse_expense("потратил 500 на еду") == (500.0, "еду") + assert eh.parse_expense("spent 12.50 on coffee") == (12.5, "coffee") + # Comma decimal (RU keyboard) + assert eh.parse_expense("потратил 12,50 на транспорт") == (12.5, "транспорт") + # No category → default + amount, cat = eh.parse_expense("потратил 100") + assert amount == 100.0 + assert cat == "разное" + # Garbage + assert eh.parse_expense("какая погода") is None + assert eh.parse_expense("потратил bla") is None + assert eh.parse_expense("потратил -100") is None + assert eh.parse_expense("") is None + + +def test_log_appends_entry(isolated_state): + eh = isolated_state('expenses_handlers', ['_PATH']) + captured = _capture(eh) + eh.do_expenses_log(voice="потратил 250 на кофе") + data = eh.load() + assert len(data['entries']) == 1 + e = data['entries'][0] + assert e['amount'] == 250.0 + assert e['category'] == "кофе" + assert "250" in captured[0] + assert "кофе" in captured[0] + + +def test_log_ignores_unparseable(isolated_state): + eh = isolated_state('expenses_handlers', ['_PATH']) + captured = _capture(eh) + eh.do_expenses_log(voice="ну блин") + assert eh.load()['entries'] == [] + assert "не понял" in captured[0].lower() + + +def test_today_sums_only_today(isolated_state): + eh = isolated_state('expenses_handlers', ['_PATH']) + # Seed data: one entry today, one entry yesterday + now = int(_time.time()) + eh._save({'entries': [ + {'ts': now, 'amount': 100.0, 'category': 'еда'}, + {'ts': now - 86400, 'amount': 999.0, 'category': 'старое'}, + ]}) + captured = _capture(eh) + eh.do_expenses_today() + assert "100" in captured[0] + assert "999" not in captured[0] + + +def test_week_sums_last_7_days(isolated_state): + eh = isolated_state('expenses_handlers', ['_PATH']) + now = int(_time.time()) + eh._save({'entries': [ + {'ts': now - 3 * 86400, 'amount': 100.0, 'category': 'a'}, + {'ts': now - 5 * 86400, 'amount': 200.0, 'category': 'b'}, + {'ts': now - 10 * 86400, 'amount': 999.0, 'category': 'старое'}, + ]}) + captured = _capture(eh) + eh.do_expenses_week() + # 100 + 200 = 300 in last 7 days; 999 excluded + assert "300" in captured[0] + assert "999" not in captured[0] + assert "2 трат" in captured[0] + + +def test_breakdown_sorts_by_amount(isolated_state): + eh = isolated_state('expenses_handlers', ['_PATH']) + now = int(_time.time()) + eh._save({'entries': [ + {'ts': now, 'amount': 100, 'category': 'small'}, + {'ts': now, 'amount': 500, 'category': 'big'}, + {'ts': now, 'amount': 200, 'category': 'medium'}, + ]}) + captured = _capture(eh) + eh.do_expenses_breakdown() + text = captured[0] + # big should appear before medium before small + assert text.index("big") < text.index("medium") < text.index("small") + + +def test_breakdown_collapses_tail(isolated_state): + eh = isolated_state('expenses_handlers', ['_PATH']) + now = int(_time.time()) + eh._save({'entries': [ + {'ts': now, 'amount': float(i + 1) * 10, 'category': f'c{i}'} + for i in range(8) # 8 categories — top 5 spoken, 3 collapse + ]}) + captured = _capture(eh) + eh.do_expenses_breakdown() + assert "и другое" in captured[0] + + +def test_today_when_empty_says_so(isolated_state): + eh = isolated_state('expenses_handlers', ['_PATH']) + captured = _capture(eh) + eh.do_expenses_today() + assert "ничего" in captured[0].lower()