"""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 `/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))