J.A.R.V.I.S-py/time_tracker_handlers.py
Bossiara13 5265cb0fbe
Some checks are pending
Python CI / pytest (ubuntu-latest) (push) Waiting to run
Python CI / pytest (windows-latest) (push) Waiting to run
Python CI / ruff (push) Waiting to run
feat: Wave 5 Python parity — outlook COM + time tracker
Outlook COM (`outlook_handlers.py`, agent):
- 4 handlers mirroring Rust pack: unread_count, latest, send_clipboard,
  summarize_inbox. Uses `win32com.client` with lazy import so the module
  loads even when pywin32 is missing — handlers degrade gracefully and
  speak a "Outlook недоступен" reply.
- `requirements.txt`: pywin32>=305 (optional).
- 7 unit tests mock `_outlook_session` via unittest.mock.

Time tracker (`time_tracker_handlers.py`, agent):
- 5 handlers: start, stop, today, week, reset. JSON store at
  `<here>/time_tracker.json`, atomic write-then-rename.
- Same shape as the Rust pack's `jarvis.state` blob: current_session_start
  + sessions[{start,end}]. Local-calendar today boundary.
- Full Russian pluralisation (час/часа/часов, минута/минуты/минут).
- 14 unit tests using the existing `isolated_state` fixture in conftest.py.

`extensions.py` + `main.py`: imports + 9 new proxy do_* functions + 9
new dispatch elifs. `commands.yaml`: 9 new entries.

Tests: 60 → 81 (+14 tracker + 7 outlook).
2026-05-16 14:34:44 +03:00

263 lines
8.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Time tracker — voice-driven daily work-time log.
Parity with the rust `time_tracker` command pack. Stores active session state
and a list of completed sessions in `<script_dir>/time_tracker.json` with
atomic write-through (write-then-rename).
Public API:
do_tracker_start(action, voice) -> None
do_tracker_stop(action, voice) -> None
do_tracker_today(action, voice) -> None
do_tracker_week(action, voice) -> None
do_tracker_reset(action, voice) -> None
Lower-level helpers (also used by tests):
load() -> dict
save(data) -> None
start_session(now=None) -> dict # returns updated state
stop_session(now=None) -> tuple[dict, int|None] # (state, elapsed seconds)
today_total(now=None) -> int # total seconds today (incl. open session)
week_total(now=None) -> int # total seconds across last 7 days
reset_today(now=None) -> tuple[int, bool] # (dropped_count, had_open)
"""
import json
import os
import time as _time
_HERE = os.path.dirname(os.path.abspath(__file__))
_PATH = os.path.join(_HERE, 'time_tracker.json')
_speak_fn = print
def set_speak(fn):
global _speak_fn
_speak_fn = fn
def _speak(text):
try:
_speak_fn(text)
except Exception as exc:
print(f"[tracker] speak: {exc}")
# ── persistence ──────────────────────────────────────────────────────────
def load() -> dict:
if not os.path.isfile(_PATH):
return {'current_session_start': None, 'sessions': []}
try:
with open(_PATH, encoding='utf-8') as f:
data = json.load(f)
except (OSError, json.JSONDecodeError) as exc:
print(f"[tracker] corrupt store ({exc}) — starting empty")
return {'current_session_start': None, 'sessions': []}
if not isinstance(data, dict):
return {'current_session_start': None, 'sessions': []}
data.setdefault('current_session_start', None)
data.setdefault('sessions', [])
return data
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 exc:
print(f"[tracker] save failed: {exc}")
# ── local-day math ───────────────────────────────────────────────────────
def _local_today_start(now: int) -> int:
"""Return the unix timestamp of local midnight on the day that contains
`now`."""
tm = _time.localtime(now)
return now - (tm.tm_hour * 3600 + tm.tm_min * 60 + tm.tm_sec)
# ── operations ───────────────────────────────────────────────────────────
def start_session(now: int | None = None) -> dict:
"""Open a new active session if none is running. Returns the new state."""
if now is None:
now = int(_time.time())
data = load()
if data.get('current_session_start') is None:
data['current_session_start'] = now
save(data)
return data
def stop_session(now: int | None = None) -> tuple[dict, int | None]:
"""Close the open session (if any). Returns (state, elapsed_seconds)."""
if now is None:
now = int(_time.time())
data = load()
start_ts = data.get('current_session_start')
if start_ts is None:
return data, None
elapsed = max(1, now - int(start_ts))
sessions = data.get('sessions') or []
sessions.append({'start': int(start_ts), 'end': now})
data['sessions'] = sessions
data['current_session_start'] = None
save(data)
return data, elapsed
def _sum_overlap(sessions: list[dict], open_start: int | None,
lo: int, hi: int) -> int:
total = 0
for sess in sessions:
s = int(sess.get('start') or 0)
e = int(sess.get('end') or 0)
a = max(s, lo)
b = min(e, hi)
if b > a:
total += b - a
if open_start is not None:
a = max(int(open_start), lo)
if hi > a:
total += hi - a
return total
def today_total(now: int | None = None) -> int:
if now is None:
now = int(_time.time())
data = load()
today_start = _local_today_start(now)
return _sum_overlap(data.get('sessions') or [],
data.get('current_session_start'),
today_start, now)
def week_total(now: int | None = None) -> int:
if now is None:
now = int(_time.time())
data = load()
today_start = _local_today_start(now)
week_start = today_start - 6 * 86400
return _sum_overlap(data.get('sessions') or [],
data.get('current_session_start'),
week_start, now)
def reset_today(now: int | None = None) -> tuple[int, bool]:
"""Drop today's completed sessions and stop any open one.
Returns (dropped_count, had_open_session)."""
if now is None:
now = int(_time.time())
data = load()
today_start = _local_today_start(now)
sessions = data.get('sessions') or []
kept = [s for s in sessions if int(s.get('start') or 0) < today_start]
dropped = len(sessions) - len(kept)
had_open = data.get('current_session_start') is not None
data['sessions'] = kept
data['current_session_start'] = None
save(data)
return dropped, had_open
# ── formatting ───────────────────────────────────────────────────────────
def _ru_hour_word(n: int) -> str:
n100 = n % 100
if 11 <= n100 <= 14:
return 'часов'
n10 = n % 10
if n10 == 1:
return 'час'
if 2 <= n10 <= 4:
return 'часа'
return 'часов'
def _ru_minute_word(n: int) -> str:
n100 = n % 100
if 11 <= n100 <= 14:
return 'минут'
n10 = n % 10
if n10 == 1:
return 'минута'
if 2 <= n10 <= 4:
return 'минуты'
return 'минут'
def _ru_session_word(n: int) -> str:
n100 = n % 100
if 11 <= n100 <= 14:
return 'сессий'
n10 = n % 10
if n10 == 1:
return 'сессия'
if 2 <= n10 <= 4:
return 'сессии'
return 'сессий'
def format_duration_ru(seconds: int) -> str:
seconds = max(0, int(seconds))
h = seconds // 3600
m = (seconds % 3600) // 60
if h > 0 and m > 0:
return f"{h} {_ru_hour_word(h)} {m} {_ru_minute_word(m)}"
if h > 0:
return f"{h} {_ru_hour_word(h)}"
return f"{m} {_ru_minute_word(m)}"
# ── voice entry points ───────────────────────────────────────────────────
def do_tracker_start(action, voice):
data = load()
if data.get('current_session_start') is not None:
_speak("Уже считаю, сэр.")
return
start_session()
_speak("Отсчёт пошёл.")
def do_tracker_stop(action, voice):
_, elapsed = stop_session()
if elapsed is None:
_speak("Трекер не запущен, сэр.")
return
_speak(f"Записал. Сессия: {format_duration_ru(elapsed)}.")
def do_tracker_today(action, voice):
total = today_total()
if total < 60:
_speak("Сегодня ещё ничего не отработано, сэр.")
return
_speak(f"Сегодня отработано {format_duration_ru(total)}.")
def do_tracker_week(action, voice):
total = week_total()
if total < 60:
_speak("За эту неделю ничего не отработано, сэр.")
return
_speak(f"За неделю отработано {format_duration_ru(total)}.")
def do_tracker_reset(action, voice):
dropped, had_open = reset_today()
if dropped == 0 and not had_open:
_speak("Сегодня сбрасывать нечего, сэр.")
return
parts = ["Подтверждаю: трекер за сегодня очищен"]
if dropped > 0:
parts.append(f", удалено {dropped} {_ru_session_word(dropped)}")
if had_open:
parts.append(", активный отсчёт остановлен")
parts.append(".")
_speak("".join(parts))