diff --git a/src/time_tracker/__main__.py b/src/time_tracker/__main__.py index dfac8b3..6f4f4ae 100644 --- a/src/time_tracker/__main__.py +++ b/src/time_tracker/__main__.py @@ -5,7 +5,9 @@ from .app import Application def main(): try: app = Application() - app.run() + from .ui.overlay import OverlayWindow + overlay = OverlayWindow(app) + overlay.run() except RuntimeError as e: print(f"Ошибка: {e}", file=sys.stderr) input("Нажмите Enter для выхода...") diff --git a/src/time_tracker/app.py b/src/time_tracker/app.py index 7d9b6cb..7864bf9 100644 --- a/src/time_tracker/app.py +++ b/src/time_tracker/app.py @@ -1,26 +1,218 @@ import json +import time +import threading from pathlib import Path -from typing import Optional -from .models import TrackerConfig, TrackedTask +from datetime import datetime +from typing import Optional, List +from pynput import mouse, keyboard + +from .models import TrackerConfig, TrackedTask, WorkDetail, WorkSession from .utils import get_platform, ensure_dir -from .ui.overlay import OverlayWindow class Application: def __init__(self): + self._lock = threading.Lock() self.config_path = Path("config.json") self.tasks_dir = Path("tasks") self.config: TrackerConfig = self._load_or_create_config() self.current_task: Optional[TrackedTask] = None self.platform = get_platform() - # Загружаем текущую задачу, если указан ID + # Состояние трекинга + self.tracking_enabled = self.config.tracking_enabled + self.last_activity: float = time.time() # timestamp + self.last_window_group: Optional[str] = None + self.is_idle: bool = False + self._tracking_thread: Optional[threading.Thread] = None + self._running = True + + # Загружаем задачу if self.config.current_task_id: self.current_task = self._load_task(self.config.current_task_id) - # Создаём директорию для задач, если нужно ensure_dir(self.tasks_dir) + # Запускаем слушатели активности (только если tracking_enabled) + self._start_activity_listeners() + # Запускаем фоновый трекер + self._start_tracking_loop() + + def _start_activity_listeners(self): + def on_activity(*_): + with self._lock: + self.last_activity = time.time() + self.is_idle = False + + self.mouse_listener = mouse.Listener(on_move=on_activity, on_click=on_activity) + self.keyboard_listener = keyboard.Listener(on_press=on_activity) + self.mouse_listener.start() + self.keyboard_listener.start() + + def _start_tracking_loop(self): + def loop(): + while self._running: + time.sleep(5) + self._check_and_update_tracking() + + self._tracking_thread = threading.Thread(target=loop, daemon=True) + self._tracking_thread.start() + + def _check_and_update_tracking(self): + with self._lock: + if not self.tracking_enabled or not self.current_task: + return + + # Проверка: спит ли система? (упрощённо — если активность > 60 сек назад) + current_time = time.time() + if current_time - self.last_activity > 60: + self.is_idle = True + self.last_window_group = None + return + + # Получаем текущее окно + window_info = self.platform.get_active_window() + if not window_info.title and not window_info.process_name: + self.is_idle = True + return + + # Определяем группу по политике + group_name = self._match_window_to_policy(window_info) + is_blocked = self._is_group_blocked(group_name) + + if is_blocked: + self.is_idle = True + self.last_window_group = None + return + + # Если рабочая группа — накапливаем время + self.is_idle = False + if self.last_window_group != group_name: + # Новая группа — завершаем предыдущую сессию (если была) + self.last_window_group = group_name + + # Добавляем 5 секунд к текущей группе + self._add_work_detail(group_name, 5.0) + + def _match_window_to_policy(self, window_info) -> str: + title = window_info.title.lower() + proc = window_info.process_name.lower() + + for policy in self.config.window_policies: + for pattern in policy.window_patterns: + if pattern.lower() in title: + return policy.group_name + for pname in policy.process_names: + if pname.lower() == proc: + return policy.group_name + return "Неизвестное приложение" + + def _is_group_blocked(self, group_name: str) -> bool: + for policy in self.config.window_policies: + if policy.group_name == group_name: + return policy.policy_type == "blocked" + return False # по умолчанию — не блокируем + + def _add_work_detail(self, group_name: str, seconds: float): + if not self.current_task: + return + + # Создаём новую сессию или используем последнюю + if not self.current_task.sessions: + session = WorkSession( + start_time=datetime.now(), + end_time=datetime.now(), + details=[], + synchronized=False + ) + self.current_task.sessions.append(session) + else: + session = self.current_task.sessions[-1] + + # Ищем detail с такой же группой + for detail in session.details: + if detail.group_name == group_name: + detail.duration_seconds += seconds + break + else: + session.details.append(WorkDetail(group_name=group_name, duration_seconds=seconds)) + + # Обновляем время сессии + session.end_time = datetime.now() + + # === Публичные методы с локом === + + def toggle_tracking(self): + with self._lock: + if not self.current_task: + self.tracking_enabled = False + return + + self.tracking_enabled = not self.tracking_enabled + self.config.tracking_enabled = self.tracking_enabled + self._save_config(self.config) + + def create_task(self, task_id: str, project_id: Optional[str], estimated_minutes: Optional[float]): + with self._lock: + ensure_dir(self.tasks_dir) + new_task = TrackedTask( + task_id=task_id, + project_id=project_id, + estimated_seconds=estimated_minutes * 60 if estimated_minutes else None, + start_time=datetime.now() + ) + self.save_task(new_task) + self.current_task = new_task + self.config.current_task_id = task_id + self.config.tracking_enabled = True + self.tracking_enabled = True + self._save_config(self.config) + + def select_task(self, task_id: str): + with self._lock: + task = self._load_task(task_id) + if task: + self.current_task = task + self.config.current_task_id = task_id + self.config.tracking_enabled = True + self.tracking_enabled = True + self._save_config(self.config) + + def add_manual_time(self, minutes: float): + with self._lock: + if not self.current_task: + return + seconds = abs(minutes) * 60 + sign = 1 if minutes >= 0 else -1 + self._add_work_detail("Manual", sign * seconds) + + def get_display_status(self) -> str: + with self._lock: + if not self.tracking_enabled: + return "Пауза" + if not self.current_task: + return "Пауза" + if self.is_idle: + return "Бездействие" + return "Активная работа" + + def get_current_window_info(self) -> dict: + with self._lock: + if not self.tracking_enabled or not self.current_task: + return {"group": "Пауза", "detail": "Трекинг остановлен. Нажмите «Пауза» для запуска."} + + if self.is_idle: + return {"group": "Бездействие", "detail": "Нет активности более 60 сек или отвлекающее окно"} + + window_info = self.platform.get_active_window() + group = self._match_window_to_policy(window_info) + return { + "group": group, + "detail": f"{window_info.title} | {window_info.process_name}" + } + + # === Остальные методы (без изменений, но с локом где нужно) === + def _load_or_create_config(self) -> TrackerConfig: if self.config_path.exists(): with open(self.config_path, "r", encoding="utf-8") as f: @@ -51,8 +243,9 @@ class Application: with open(task_file, "w", encoding="utf-8") as f: f.write(task.model_dump_json(indent=2)) - def run(self): - # Пока просто запускаем UI - # Позже сюда добавим трекинг в фоне - overlay = OverlayWindow() - overlay.run() \ No newline at end of file + def shutdown(self): + self._running = False + if hasattr(self, 'mouse_listener'): + self.mouse_listener.stop() + if hasattr(self, 'keyboard_listener'): + self.keyboard_listener.stop() \ No newline at end of file diff --git a/src/time_tracker/ui/adjust_time_window.py b/src/time_tracker/ui/adjust_time_window.py index 6eb7658..4dbc11d 100644 --- a/src/time_tracker/ui/adjust_time_window.py +++ b/src/time_tracker/ui/adjust_time_window.py @@ -1,9 +1,18 @@ import tkinter as tk +from datetime import datetime from tkinter import Toplevel, Label, Entry, Button +from typing import Callable, TYPE_CHECKING + +if TYPE_CHECKING: + from ..app import Application class AdjustTimeWindow: - def __init__(self, parent): + def __init__(self, parent, app: "Application", on_close_callback: Callable): + self.parent = parent + self.app = app + self.on_close_callback = on_close_callback + self.window = Toplevel(parent) self.window.title("Изменить время") self.window.geometry("250x120") @@ -20,5 +29,39 @@ class AdjustTimeWindow: Button(self.window, text="Применить", command=self.on_apply).pack(pady=5) def on_apply(self): - # Позже: применить изменение + if not self.app.current_task: + self.window.destroy() + return + + try: + minutes = float(self.minutes_entry.get()) + except ValueError: + return + + if minutes == 0: + self.window.destroy() + return + + seconds = abs(minutes * 60) + # Создаём новую сессию или добавляем в последнюю? + # Для простоты — добавим новую сессию с одним WorkDetail + + from ..models import WorkDetail, WorkSession + + detail = WorkDetail( + group_name="Manual", + duration_seconds=seconds if minutes > 0 else -seconds # можно хранить отрицательное + ) + + session = WorkSession( + start_time=datetime.now(), + end_time=datetime.now(), + details=[detail], + synchronized=False + ) + + self.app.current_task.sessions.append(session) + self.app.save_task(self.app.current_task) + + self.on_close_callback() self.window.destroy() \ No newline at end of file diff --git a/src/time_tracker/ui/new_task_window.py b/src/time_tracker/ui/new_task_window.py index c9a83ab..ae5798e 100644 --- a/src/time_tracker/ui/new_task_window.py +++ b/src/time_tracker/ui/new_task_window.py @@ -1,14 +1,23 @@ import tkinter as tk -from tkinter import Toplevel, Label, Entry, Button, Frame +from datetime import datetime +from tkinter import Toplevel, Label, Entry, Button +from typing import Callable, TYPE_CHECKING + +if TYPE_CHECKING: + from ..app import Application class NewTaskWindow: - def __init__(self, parent): + def __init__(self, parent, app: "Application", on_close_callback: Callable): + self.parent = parent + self.app = app + self.on_close_callback = on_close_callback + self.window = Toplevel(parent) self.window.title("Новая задача") self.window.geometry("300x200") self.window.transient(parent) - self.window.grab_set() # модальное + self.window.grab_set() Label(self.window, text="ID задачи:").pack(pady=(10, 0)) self.task_id_entry = Entry(self.window) @@ -25,5 +34,36 @@ class NewTaskWindow: Button(self.window, text="Создать", command=self.on_create).pack(pady=10) def on_create(self): - # Позже: сохранить задачу + task_id = self.task_id_entry.get().strip() + if not task_id: + return + + try: + estimate_str = self.estimate_entry.get().strip() + estimate_seconds = float(estimate_str) * 60 if estimate_str else None + except ValueError: + return + + project_id = self.project_id_entry.get().strip() or None + + # Создаём новую задачу + from ..models import TrackedTask + new_task = TrackedTask( + task_id=task_id, + project_id=project_id, + estimated_seconds=estimate_seconds, + start_time=datetime.now() + ) + + # Сохраняем + self.app.save_task(new_task) + + # Обновляем конфиг + self.app.config.current_task_id = task_id + self.app._save_config(self.app.config) + + # Обновляем текущую задачу в памяти + self.app.current_task = new_task + + self.on_close_callback() self.window.destroy() \ No newline at end of file diff --git a/src/time_tracker/ui/overlay.py b/src/time_tracker/ui/overlay.py index ea8b780..7729058 100644 --- a/src/time_tracker/ui/overlay.py +++ b/src/time_tracker/ui/overlay.py @@ -1,10 +1,14 @@ import tkinter as tk from tkinter import Menu, Label, Button, Frame -from typing import Optional +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from ..app import Application + class OverlayWindow: - def __init__(self): + def __init__(self, app: "Application"): + self.app = app self.root = tk.Tk() self.root.title("Citrus Time Tracker") self.root.geometry("320x180") @@ -30,6 +34,8 @@ class OverlayWindow: # Содержимое self._build_ui() + self._auto_update() + def center_window(self): self.root.update_idletasks() screen_width = self.root.winfo_screenwidth() @@ -66,27 +72,72 @@ class OverlayWindow: btn_frame = Frame(main_frame) btn_frame.pack(side="bottom", fill="x", pady=(10, 0)) - Button(btn_frame, text="⏸ Пауза", command=self.on_pause).pack(side="left", fill="x", expand=True, padx=(0, 2)) - Button(btn_frame, text="➕ Новая", command=self.on_new_task).pack(side="left", fill="x", expand=True, padx=2) - Button(btn_frame, text="🔍 Выбрать", command=self.on_select_task).pack(side="left", fill="x", expand=True, padx=2) - Button(btn_frame, text="✏️ Время", command=self.on_adjust_time).pack(side="left", fill="x", expand=True, padx=(2, 0)) + self.pause_button = Button(btn_frame, text="⏸ Пауза", command=self.on_pause) + self.pause_button.pack(side="left", fill="x", expand=True, padx=(0, 2)) + self.new_task_btn = Button(btn_frame, text="➕ Новая", command=self.on_new_task) + self.new_task_btn.pack(side="left", fill="x", expand=True, padx=2) + self.select_task_btn = Button(btn_frame, text="🔍 Выбрать", command=self.on_select_task) + self.select_task_btn.pack(side="left", fill="x", expand=True, padx=2) + self.adjust_time_btn = Button(btn_frame, text="✏️ Время", command=self.on_adjust_time) + self.adjust_time_btn.pack(side="left", fill="x", expand=True, padx=(2, 0)) + + self._update_display() + + def _update_display(self): + status = self.app.get_display_status() + self.status_label.config(text=f"Citrus Time Tracker: {status}") + + task_id = self.app.config.current_task_id or "<Не выбрано>" + self.task_label.config(text=f"Задача: {task_id}") + + # Время + if self.app.current_task: + worked = self.app.current_task.total_worked_seconds / 60 + est = self.app.current_task.estimated_seconds + if est is not None: + est_min = est / 60 + rem = self.app.current_task.remaining_seconds / 60 + time_info = f"Затрачено: {worked:.1f} мин | Оценка: {est_min:.1f} мин" + else: + time_info = f"Затрачено: {worked:.1f} мин | Оценка: —" + else: + time_info = "Задача не выбрана" + self.time_info_label.config(text=time_info) + + # Окно + win_info = self.app.get_current_window_info() + self.window_group_label.config(text=win_info["group"]) + self.window_detail_label.config(text=win_info["detail"]) + + # Активность кнопок + has_task = self.app.current_task is not None + self.pause_button.config(state="normal" if has_task else "disabled") + for btn in [self.new_task_btn, self.select_task_btn, self.adjust_time_btn]: + btn.config(state="normal") + + # Но "Добавить время" — только если есть задача + self.adjust_time_btn.config(state="normal" if has_task else "disabled") + + def _auto_update(self): + self._update_display() + self.root.after(2000, self._auto_update) # обновляем каждые 2 сек # === Обработчики событий (пока без логики, только открытие окон) === def on_pause(self): - pass # будет реализовано позже + self.app.toggle_tracking() def on_new_task(self): from .new_task_window import NewTaskWindow - NewTaskWindow(self.root) + NewTaskWindow(self.root, self.app, self._update_display) def on_select_task(self): from .select_task_window import SelectTaskWindow - SelectTaskWindow(self.root) + SelectTaskWindow(self.root, self.app, self._update_display) def on_adjust_time(self): from .adjust_time_window import AdjustTimeWindow - AdjustTimeWindow(self.root) + AdjustTimeWindow(self.root, self.app, self._update_display) # === UI-управление === diff --git a/src/time_tracker/ui/select_task_window.py b/src/time_tracker/ui/select_task_window.py index 22ebc5c..af6a366 100644 --- a/src/time_tracker/ui/select_task_window.py +++ b/src/time_tracker/ui/select_task_window.py @@ -1,32 +1,55 @@ import tkinter as tk -from tkinter import Toplevel, Listbox, Scrollbar, Button, Frame +from pathlib import Path +from tkinter import Toplevel, Listbox, Scrollbar, Button +from typing import Callable, TYPE_CHECKING + +if TYPE_CHECKING: + from ..app import Application class SelectTaskWindow: - def __init__(self, parent): + def __init__(self, parent, app: "Application", on_close_callback: Callable): + self.parent = parent + self.app = app + self.on_close_callback = on_close_callback + self.window = Toplevel(parent) self.window.title("Выбрать задачу") self.window.geometry("300x250") self.window.transient(parent) self.window.grab_set() - list_frame = Frame(self.window) + # Сканируем папку tasks/ + task_files = list(Path("tasks").glob("*.json")) if Path("tasks").exists() else [] + self.task_ids = [f.stem for f in task_files] + + list_frame = tk.Frame(self.window) list_frame.pack(fill="both", expand=True, padx=10, pady=10) scrollbar = Scrollbar(list_frame) scrollbar.pack(side="right", fill="y") self.listbox = Listbox(list_frame, yscrollcommand=scrollbar.set) + for tid in self.task_ids: + self.listbox.insert("end", tid) self.listbox.pack(side="left", fill="both", expand=True) - # Заглушка: список задач - for i in range(5): - self.listbox.insert("end", f"TASK-{i+1} (1 ч 20 мин)") - scrollbar.config(command=self.listbox.yview) Button(self.window, text="Выбрать", command=self.on_select).pack(pady=5) def on_select(self): - # Позже: выбрать задачу + selection = self.listbox.curselection() + if not selection: + return + task_id = self.task_ids[selection[0]] + + # Загружаем задачу + task = self.app._load_task(task_id) + if task: + self.app.current_task = task + self.app.config.current_task_id = task_id + self.app._save_config(self.app.config) + self.on_close_callback() + self.window.destroy() \ No newline at end of file