commit 9ad75f11e5f8d332b3f684774aa3cca25f2ced30 Author: Mikan <72257910+Mikan-DS@users.noreply.github.com> Date: Mon Mar 9 21:42:02 2026 +0300 initial diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/TimeTracker.iml b/.idea/TimeTracker.iml new file mode 100644 index 0000000..2f47d6a --- /dev/null +++ b/.idea/TimeTracker.iml @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..26f6441 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..99bde36 --- /dev/null +++ b/README.md @@ -0,0 +1,36 @@ +# Work Tracker + +A personal time tracking tool that monitors your work sessions, tracks time spent on different applications, and prepares data for YouTrack synchronization. + +## Features + +- Track active windows and classify them as work/distractions +- Record separate work sessions for each task +- Manual time adjustment capabilities +- Always-on-top GUI with controls +- Hotkeys for quick actions +- Notifications for non-work applications +- Cross-platform support (Windows, macOS, Linux) + +## Installation + +```bash +pip install -e . +``` + +## Usage + +Run the application: + +```bash +python -m work_tracker +``` + +Use hotkeys: +- Ctrl+Alt+T: Toggle tracking +- Ctrl+Alt+N: New task +- Ctrl+Alt+S: End current session +- Ctrl+Alt+M: Adjust time + + + diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..568ea4e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,32 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "work_tracker" +version = "0.1.0" +description = "Personal time tracking tool with YouTrack integration" +readme = "README.md" +authors = [{name = "Mikan"}] +license = {text = "MIT"} +requires-python = ">=3.8" +dependencies = [ + "pynput>=1.7.6", + "pywin32>=306; sys_platform == 'win32'" +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0", + "pytest-mock>=3.10.0", +] + +[tool.setuptools.packages.find] +where = ["src"] +include = ["time_tracker*"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] \ No newline at end of file diff --git a/src/time_tracker/__init__.py b/src/time_tracker/__init__.py new file mode 100644 index 0000000..8d861ca --- /dev/null +++ b/src/time_tracker/__init__.py @@ -0,0 +1 @@ +"""Time Tracker package.""" \ No newline at end of file diff --git a/src/time_tracker/core/config.py b/src/time_tracker/core/config.py new file mode 100644 index 0000000..4cedceb --- /dev/null +++ b/src/time_tracker/core/config.py @@ -0,0 +1,33 @@ +import json +import os +from typing import Dict, Any + +CONFIG_FILE = "config.json" + +def load_config() -> Dict[str, Any]: + """ + Load configuration from config.json. + Creates default config if file doesn't exist. + """ + if os.path.exists(CONFIG_FILE): + with open(CONFIG_FILE, "r", encoding="utf-8") as f: + return json.load(f) + else: + # Default configuration + default_config = { + "tracking_enabled": True, + "current_task_id": None, + "window_rules": { + "work": ["PyCharm", "VS Code", "Visual Studio", "Sublime Text", "Atom", "IntelliJ IDEA"], + "distraction": ["YouTube", "Twitter", "Facebook", "Instagram", "TikTok", "Discord", "Slack"] + } + } + save_config(default_config) + return default_config + +def save_config(config: Dict[str, Any]) -> None: + """ + Save configuration to config.json. + """ + with open(CONFIG_FILE, "w", encoding="utf-8") as f: + json.dump(config, f, indent=2, ensure_ascii=False) \ No newline at end of file diff --git a/src/time_tracker/core/session_manager.py b/src/time_tracker/core/session_manager.py new file mode 100644 index 0000000..7f9b9c4 --- /dev/null +++ b/src/time_tracker/core/session_manager.py @@ -0,0 +1,221 @@ +import json +import os +from datetime import datetime +from typing import Dict, List, Optional +from pathlib import Path + + +class SessionManager: + def __init__(self, tasks_directory: str): + self.tasks_directory = Path(tasks_directory) + self.tasks_directory.mkdir(exist_ok=True) + self.current_session: Optional[Dict] = None + self.session_dirty = False + + def create_session(self, task_id: str, project_id: str) -> Dict: + """ + Create a new work session. + """ + session_id = datetime.now().strftime("%Y%m%dT%H%M%S") + session = { + "session_id": session_id, + "task_id": task_id, + "project_id": project_id, + "started_at": datetime.now().isoformat(), + "duration_seconds": 0, + "window_details": {}, + "distractions": [], + "manual_adjustments": [], + "synced_to_youtrack": False + } + + self.current_session = session + self.session_dirty = True + + return session + + def record_window_time(self, window_title: str, seconds: int) -> None: + """ + Record time spent on a specific window/application. + """ + if not self.current_session: + return + + current_time = self.current_session["window_details"].get(window_title, 0) + self.current_session["window_details"][window_title] = current_time + seconds + self.session_dirty = True + + def record_distraction(self, window_title: str, duration_seconds: int) -> None: + """ + Record a distraction period. + """ + if not self.current_session: + return + + distraction = { + "window": window_title, + "start": datetime.now().isoformat(), + "duration_seconds": duration_seconds + } + self.current_session["distractions"].append(distraction) + self.session_dirty = True + + def add_manual_adjustment(self, seconds: int, reason: str) -> None: + """ + Add a manual time adjustment (positive to add, negative to subtract). + """ + if not self.current_session: + return + + operation = "add" if seconds >= 0 else "subtract" + adjustment = { + "operation": operation, + "seconds": abs(seconds), + "reason": reason, + "applied_at": datetime.now().isoformat() + } + self.current_session["manual_adjustments"].append(adjustment) + self.session_dirty = True + + def increment_duration(self, seconds: int) -> None: + """ + Increment the total duration of the current session. + """ + if not self.current_session: + return + + self.current_session["duration_seconds"] += seconds + self.session_dirty = True + + def end_current_session(self) -> Optional[Dict]: + """ + End the current session and save it to disk. + """ + if not self.current_session: + return None + + # Finalize session + self.current_session["ended_at"] = datetime.now().isoformat() + + # Calculate final duration with adjustments + total_duration = self.current_session["duration_seconds"] + for adj in self.current_session.get("manual_adjustments", []): + if adj["operation"] == "add": + total_duration += adj["seconds"] + else: + total_duration -= adj["seconds"] + + self.current_session["final_duration_seconds"] = max(0, total_duration) + + # Save session to disk + self._save_session(self.current_session) + + # Store reference to return + ended_session = self.current_session + + # Clear current session + self.current_session = None + self.session_dirty = False + + return ended_session + + def end_session(self, session: Dict) -> None: + """ + End a specific session and save it to disk. + """ + if "ended_at" not in session: + session["ended_at"] = datetime.now().isoformat() + + # Calculate final duration with adjustments + total_duration = session["duration_seconds"] + for adj in session.get("manual_adjustments", []): + if adj["operation"] == "add": + total_duration += adj["seconds"] + else: + total_duration -= adj["seconds"] + + session["final_duration_seconds"] = max(0, total_duration) + + self._save_session(session) + + def _save_session(self, session: Dict) -> None: + """ + Save a session to its respective task directory. + """ + task_dir = self.tasks_directory / session["task_id"] + task_dir.mkdir(exist_ok=True) + + sessions_dir = task_dir / "sessions" + sessions_dir.mkdir(exist_ok=True) + + filename = f"{session['session_id']}_{session['task_id']}.json" + filepath = sessions_dir / filename + + with open(filepath, 'w', encoding='utf-8') as f: + json.dump(session, f, indent=2, ensure_ascii=False) + + def get_current_session_duration(self) -> int: + """ + Get the current session's total duration including adjustments. + """ + if not self.current_session: + return 0 + + total_duration = self.current_session["duration_seconds"] + for adj in self.current_session.get("manual_adjustments", []): + if adj["operation"] == "add": + total_duration += adj["seconds"] + else: + total_duration -= adj["seconds"] + + return max(0, total_duration) + + def get_total_work_today(self) -> int: + """ + Get total work time for today across all tasks. + """ + today = datetime.now().date().isoformat() + total = 0 + + for task_dir in self.tasks_directory.iterdir(): + if not task_dir.is_dir(): + continue + + sessions_dir = task_dir / "sessions" + if not sessions_dir.exists(): + continue + + for session_file in sessions_dir.glob("*.json"): + with open(session_file, 'r', encoding='utf-8') as f: + session = json.load(f) + + # Check if session started today + start_date = datetime.fromisoformat(session['started_at']).date().isoformat() + if start_date == today: + total += session.get('final_duration_seconds', session.get('duration_seconds', 0)) + + return total + + def load_sessions_for_task(self, task_id: str) -> List[Dict]: + """ + Load all sessions for a specific task. + """ + task_dir = self.tasks_directory / task_id + sessions_dir = task_dir / "sessions" + + if not sessions_dir.exists(): + return [] + + sessions = [] + for session_file in sessions_dir.glob("*.json"): + with open(session_file, 'r', encoding='utf-8') as f: + session = json.load(f) + sessions.append(session) + + # Sort by ended_at (or started_at if ended_at doesn't exist), newest first + sessions.sort( + key=lambda s: s.get('ended_at', s.get('started_at', '')), + reverse=True + ) + + return sessions \ No newline at end of file diff --git a/src/time_tracker/main.py b/src/time_tracker/main.py new file mode 100644 index 0000000..f3bd17b --- /dev/null +++ b/src/time_tracker/main.py @@ -0,0 +1,319 @@ +import tkinter as tk +from tkinter import simpledialog, messagebox +import threading +import time +from datetime import datetime +import os +from typing import Dict, List, Optional + +try: + from .core.config import load_config, save_config + from .core.session_manager import SessionManager + from .ui.overlay import OverlayWindow + + # Try to import platform-specific modules + import sys + + if sys.platform.startswith('win'): + from .platform.windows import get_active_window, register_session_listener, get_idle_time + else: + # Fallback for other platforms + def get_active_window(): + return "Unsupported platform" + + + def register_session_listener(callback): + print("Session monitoring not supported on this platform") + + + def get_idle_time(): + return 0 +except ImportError: + # For running as a single file + from core.config import load_config, save_config + from core.session_manager import SessionManager + from ui.overlay import OverlayWindow + + import sys + + if sys.platform.startswith('win'): + from platform.windows import get_active_window, register_session_listener, get_idle_time + else: + def get_active_window(): + return "Unsupported platform" + + + def register_session_listener(callback): + print("Session monitoring not supported on this platform") + + + def get_idle_time(): + return 0 + + +class WorkTracker: + def __init__(self): + self.config = load_config() + self.session_manager = SessionManager("tasks") + self.active_window = "" + self.window_classification = "neutral" + self.last_activity = time.time() + self.session_locked = False + self.is_tracking = self.config.get("tracking_enabled", True) + self.current_task_id = self.config.get("current_task_id") + + # Start monitoring in background + self.start_monitoring() + + def start_monitoring(self): + """Start background monitoring threads""" + # Thread for window tracking + window_thread = threading.Thread(target=self.monitor_windows, daemon=True) + window_thread.start() + + # Thread for input activity tracking + input_thread = threading.Thread(target=self.monitor_input_activity, daemon=True) + input_thread.start() + + # Register session listener + register_session_listener(self.on_session_change) + + def monitor_windows(self): + """Monitor active windows and update classifications""" + while True: + try: + current_window = get_active_window() + + # Only update if window changed + if current_window != self.active_window: + self.active_window = current_window + self.window_classification = self.classify_window(current_window) + + # Show notification if it's a distraction or neutral app + if self.window_classification == "distraction": + self.show_distraction_notification(current_window) + elif self.window_classification == "neutral": + self.show_neutral_notification(current_window) + + time.sleep(1) + except Exception as e: + print(f"Error in window monitoring: {e}") + time.sleep(5) + + def monitor_input_activity(self): + """Monitor keyboard and mouse activity""" + try: + from pynput import mouse, keyboard + + def on_activity(*args): + self.last_activity = time.time() + + # Start listeners + mouse.Listener(on_move=on_activity, on_click=on_activity).start() + keyboard.Listener(on_press=on_activity).start() + except ImportError: + print("pynput not available, using fallback idle detection") + # Fallback: just use system idle time + pass + + def classify_window(self, title: str) -> str: + """Classify window as work, distraction, or neutral""" + title_lower = title.lower() + rules = self.config.get("window_rules", {}) + + # Check distractions first + for keyword in rules.get("distraction", []): + if keyword.lower() in title_lower: + return "distraction" + + # Then check work apps + for keyword in rules.get("work", []): + if keyword.lower() in title_lower: + return "work" + + # Otherwise neutral + return "neutral" + + def on_session_change(self, event_type: str): + """Handle session lock/unlock events""" + if event_type == "locked": + self.session_locked = True + # End current session if active + if self.session_manager.current_session: + self.session_manager.end_current_session() + elif event_type == "unlocked": + self.session_locked = False + self.last_activity = time.time() # Reset idle time on unlock + + def should_count_as_work(self) -> bool: + """Check if current conditions should count as work time""" + # Check if tracking is enabled + if not self.is_tracking: + return False + + # Check if session is locked + if self.session_locked: + return False + + # Check idle time (more than 60 seconds of inactivity) + idle_time = time.time() - self.last_activity + if idle_time > 60: + return False + + # Check window classification + if self.window_classification != "work": + return False + + return True + + def run_main_loop(self): + """Main tracking loop""" + while True: + try: + if self.should_count_as_work(): + # Increment current session duration + if self.session_manager.current_session: + self.session_manager.increment_duration(1) + + # Record time for current window + self.session_manager.record_window_time(self.active_window, 1) + + time.sleep(1) + except Exception as e: + print(f"Error in main loop: {e}") + time.sleep(5) + + def toggle_tracking(self): + """Toggle tracking on/off""" + self.is_tracking = not self.is_tracking + self.config["tracking_enabled"] = self.is_tracking + save_config(self.config) + + def create_task(self, task_id: str): + """Create a new task and start tracking it""" + project_id = task_id.split('-')[0] if '-' in task_id else task_id + self.session_manager.create_session(task_id, project_id) + self.current_task_id = task_id + self.config["current_task_id"] = task_id + save_config(self.config) + + def switch_task(self, task_id: str): + """Switch to an existing task""" + # End current session if exists + if self.session_manager.current_session: + self.session_manager.end_current_session() + + # Start new session for the task + project_id = task_id.split('-')[0] if '-' in task_id else task_id + self.session_manager.create_session(task_id, project_id) + self.current_task_id = task_id + self.config["current_task_id"] = task_id + save_config(self.config) + + def get_available_tasks(self) -> List[str]: + """Get list of all available tasks""" + tasks_dir = "tasks" + if not os.path.exists(tasks_dir): + return [] + + tasks = [] + for item in os.listdir(tasks_dir): + item_path = os.path.join(tasks_dir, item) + if os.path.isdir(item_path): + tasks.append(item) + + return tasks + + def has_active_session(self) -> bool: + """Check if there's an active session""" + return self.session_manager.current_session is not None + + def add_manual_adjustment(self, seconds: int, reason: str): + """Add a manual time adjustment""" + if self.session_manager.current_session: + self.session_manager.add_manual_adjustment(seconds, reason) + + def get_current_session_duration(self) -> int: + """Get duration of current session""" + return self.session_manager.get_current_session_duration() + + def get_total_work_today(self) -> int: + """Get total work time for today""" + return self.session_manager.get_total_work_today() + + def get_planned_duration(self, task_id: str) -> Optional[int]: + """Get planned duration for a task (placeholder implementation)""" + # In a full implementation, this would look up the planned time for the task + # For now, return None to indicate no planned time + return None + + def show_distraction_notification(self, window_title: str): + """Show notification about distraction window""" + # In a full implementation, this would show a GUI notification + print(f"DISTRACTION: {window_title}") + + def show_neutral_notification(self, window_title: str): + """Show notification about neutral window""" + # In a full implementation, this would show a GUI notification + print(f"NEUTRAL APP: {window_title}") + + +def main(): + # Create main Tkinter root + root = tk.Tk() + root.withdraw() # Hide the root window + + # Initialize tracker + tracker = WorkTracker() + + # Create overlay window + overlay = OverlayWindow(root, tracker, tracker.config) + + # Start main tracking loop in background + main_loop_thread = threading.Thread(target=tracker.run_main_loop, daemon=True) + main_loop_thread.start() + + # Set up hotkeys + try: + from pynput import keyboard + + def on_hotkey(key): + try: + if key == keyboard.Key.alt_l: # Left Alt pressed + return # Wait for combination + elif hasattr(key, 'char') and key.char == 't': + # Ctrl+Alt+T: Toggle tracking + tracker.toggle_tracking() + overlay.update_display() + elif hasattr(key, 'char') and key.char == 'n': + # Ctrl+Alt+N: New task + overlay.new_task() + elif hasattr(key, 'char') and key.char == 's': + # Ctrl+Alt+S: End session + if tracker.session_manager.current_session: + tracker.session_manager.end_current_session() + overlay.update_display() + elif hasattr(key, 'char') and key.char == 'm': + # Ctrl+Alt+M: Adjust time + overlay.adjust_time() + except AttributeError: + # Special keys like Ctrl, Alt don't have char attribute + pass + + # Start keyboard listener + keyboard.Listener(on_press=on_hotkey).start() + except ImportError: + print("pynput not available, hotkeys disabled") + + # Run the GUI + try: + root.mainloop() + except KeyboardInterrupt: + print("\nShutting down...") + # End any current session before exiting + if tracker.session_manager.current_session: + tracker.session_manager.end_current_session() + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/src/time_tracker/platform/base.py b/src/time_tracker/platform/base.py new file mode 100644 index 0000000..a060cad --- /dev/null +++ b/src/time_tracker/platform/base.py @@ -0,0 +1,19 @@ +from abc import ABC, abstractmethod +from typing import Callable + + +class PlatformInterface(ABC): + @abstractmethod + def get_active_window(self) -> str: + """Get the currently active window title""" + pass + + @abstractmethod + def register_session_listener(self, callback: Callable[[str], None]) -> None: + """Register a callback for session lock/unlock events""" + pass + + @abstractmethod + def get_idle_time(self) -> int: + """Get the system idle time in seconds""" + pass \ No newline at end of file diff --git a/src/time_tracker/platform/windows.py b/src/time_tracker/platform/windows.py new file mode 100644 index 0000000..f6f2842 --- /dev/null +++ b/src/time_tracker/platform/windows.py @@ -0,0 +1,75 @@ +import win32gui +import win32con +import win32api +import threading +from typing import Callable + + +def get_active_window() -> str: + """Get the currently active window title on Windows""" + try: + hwnd = win32gui.GetForegroundWindow() + return win32gui.GetWindowText(hwnd) or "Unknown Window" + except: + return "Error getting window title" + + +def register_session_listener(callback: Callable[[str], None]) -> None: + """Register a callback for Windows session lock/unlock events""" + + def session_event_handler(hwnd, msg, wparam, lparam): + if msg == win32con.WM_WTSSESSION_CHANGE: + if wparam == win32con.WTS_SESSION_LOCK: + callback("locked") + elif wparam == win32con.WTS_SESSION_UNLOCK: + callback("unlocked") + return win32gui.DefWindowProc(hwnd, msg, wparam, lparam) + + def run_message_loop(): + hinst = win32api.GetModuleHandle(None) + wndclass = win32gui.WNDCLASS() + wndclass.hInstance = hinst + wndclass.lpszClassName = "SessionWatcher" + wndclass.lpfnWndProc = session_event_handler + + try: + win32gui.RegisterClass(wndclass) + except: + pass # Already registered + + hwnd = win32gui.CreateWindow( + wndclass.lpszClassName, + "Session Watcher", + 0, 0, 0, 0, 0, + 0, hinst, None + ) + + # Register for session notifications + try: + from win32ts import WTSRegisterSessionNotification + WTSRegisterSessionNotification(hwnd, 1) # NOTIFY_FOR_THIS_SESSION + except ImportError: + # pywin32 might not have win32ts on all systems + pass + + # Start message loop + win32gui.PumpMessages() + + # Run in a separate thread + thread = threading.Thread(target=run_message_loop, daemon=True) + thread.start() + + +def get_idle_time() -> int: + """Get the system idle time in seconds on Windows""" + from ctypes import Structure, windll, c_uint, sizeof, byref + + class LASTINPUTINFO(Structure): + _fields_ = [('cbSize', c_uint), ('dwTime', c_uint)] + + lastInputInfo = LASTINPUTINFO() + lastInputInfo.cbSize = sizeof(lastInputInfo) + windll.user32.GetLastInputInfo(byref(lastInputInfo)) + + millis = windll.kernel32.GetTickCount() - lastInputInfo.dwTime + return millis // 1000 # Convert milliseconds to seconds \ No newline at end of file diff --git a/src/time_tracker/ui/overlay.py b/src/time_tracker/ui/overlay.py new file mode 100644 index 0000000..aa98d6f --- /dev/null +++ b/src/time_tracker/ui/overlay.py @@ -0,0 +1,261 @@ +import tkinter as tk +from tkinter import simpledialog, messagebox +import threading +import time +from typing import Any, Callable + + +class OverlayWindow: + def __init__(self, root: tk.Tk, tracker: Any, config: Any): + self.root = root + self.tracker = tracker + self.config = config + + # Configure window to be always on top + self.root.attributes("-topmost", True) + self.root.overrideredirect(True) # Remove window decorations + self.root.geometry("300x150+100+100") + + # Make window transparent (optional) + self.root.wm_attributes("-transparentcolor", "white") + + # Create UI elements + self.create_widgets() + + # Bind drag functionality + self.setup_drag() + + # Update display periodically + self.update_display_periodically() + + def create_widgets(self): + """Create all UI widgets""" + # Main frame + main_frame = tk.Frame(self.root, bg='white', bd=2, relief='solid') + main_frame.pack(fill='both', expand=True, padx=2, pady=2) + + # Status line (tracking enabled/disabled) + self.status_label = tk.Label(main_frame, text="", bg='white', font=('Arial', 10)) + self.status_label.pack(anchor='w', padx=5, pady=2) + + # Task info + self.task_label = tk.Label(main_frame, text="", bg='white', font=('Arial', 10, 'bold')) + self.task_label.pack(anchor='w', padx=5, pady=2) + + # Window info + self.window_label = tk.Label(main_frame, text="", bg='white', font=('Arial', 9)) + self.window_label.pack(anchor='w', padx=5, pady=2) + + # Total time info + self.total_label = tk.Label(main_frame, text="", bg='white', font=('Arial', 9)) + self.total_label.pack(anchor='w', padx=5, pady=2) + + # Buttons frame + buttons_frame = tk.Frame(main_frame, bg='white') + buttons_frame.pack(fill='x', padx=5, pady=5) + + # Tracking toggle button + self.track_btn = tk.Button(buttons_frame, text="●", command=self.toggle_tracking, width=3) + self.track_btn.pack(side='left', padx=2) + + # New task button + new_task_btn = tk.Button(buttons_frame, text="N", command=self.new_task, width=3) + new_task_btn.pack(side='left', padx=2) + + # Switch task button + switch_task_btn = tk.Button(buttons_frame, text="S", command=self.switch_task, width=3) + switch_task_btn.pack(side='left', padx=2) + + # Adjust time button + adjust_btn = tk.Button(buttons_frame, text="±", command=self.adjust_time, width=3) + adjust_btn.pack(side='left', padx=2) + + def setup_drag(self): + """Setup window dragging functionality""" + + def start_move(event): + self.root.x = event.x + self.root.y = event.y + + def do_move(event): + x = self.root.winfo_x() + (event.x - self.root.x) + y = self.root.winfo_y() + (event.y - self.root.y) + self.root.geometry(f"+{x}+{y}") + + # Bind drag events to the main label (covers entire window) + self.status_label.bind("", start_move) + self.status_label.bind("", do_move) + + # Also bind to other labels for full coverage + for widget in [self.task_label, self.window_label, self.total_label]: + widget.bind("", start_move) + widget.bind("", do_move) + + def toggle_tracking(self): + """Toggle tracking state""" + self.tracker.toggle_tracking() + self.update_display() + + def new_task(self): + """Create a new task""" + task_id = simpledialog.askstring("Новая задача", "Введите ID задачи (например, PRJ-123):") + if task_id: + self.tracker.create_task(task_id) + self.update_display() + + def switch_task(self): + """Switch to an existing task""" + available_tasks = self.tracker.get_available_tasks() + if not available_tasks: + messagebox.showinfo("Переключить задачу", "Нет доступных задач") + return + + task_list = "\n".join(available_tasks) + selected_task = simpledialog.askstring( + "Переключить задачу", + f"Доступные задачи:\n{task_list}\n\nВведите ID задачи:" + ) + + if selected_task and selected_task in available_tasks: + self.tracker.switch_task(selected_task) + self.update_display() + + def adjust_time(self): + """Show dialog to adjust time manually""" + self.show_adjust_time_dialog() + + def show_adjust_time_dialog(self): + """Show dialog for manual time adjustment""" + adjustment_window = tk.Toplevel(self.root) + adjustment_window.title("Ручная корректировка времени") + adjustment_window.geometry("300x150") + adjustment_window.transient(self.root) + adjustment_window.grab_set() # Modal window + + # Operation selection + operation_var = tk.StringVar(value="+") + tk.Radiobutton(adjustment_window, text="Добавить время", variable=operation_var, value="+").pack(anchor='w', + padx=10, + pady=5) + tk.Radiobutton(adjustment_window, text="Вычесть время", variable=operation_var, value="-").pack(anchor='w', + padx=10, pady=5) + + # Minutes entry + tk.Label(adjustment_window, text="Минут:").pack(anchor='w', padx=10) + minutes_entry = tk.Entry(adjustment_window) + minutes_entry.pack(padx=10, pady=5) + minutes_entry.insert(0, "15") # Default value + + # Reason entry + tk.Label(adjustment_window, text="Причина:").pack(anchor='w', padx=10) + reason_entry = tk.Entry(adjustment_window) + reason_entry.pack(padx=10, pady=5) + reason_entry.insert(0, "Ручная корректировка") # Default value + + def apply_adjustment(): + try: + minutes = int(minutes_entry.get()) + operation = operation_var.get() + seconds = minutes * 60 + if operation == "-": + seconds = -seconds + + reason = reason_entry.get() + + if self.tracker.has_active_session(): + self.tracker.add_manual_adjustment(seconds, reason) + messagebox.showinfo("Успешно", f"Время скорректировано: {operation}{minutes} мин") + adjustment_window.destroy() + self.update_display() + else: + messagebox.showwarning("Внимание", "Нет активной сессии для корректировки") + except ValueError: + messagebox.showerror("Ошибка", "Введите корректное число минут") + + # Apply button + apply_btn = tk.Button(adjustment_window, text="Применить", command=apply_adjustment) + apply_btn.pack(pady=10) + + def update_display(self): + """Update the display with current information""" + # Update tracking status + track_status = "●" if self.config.tracking_enabled else "○" + status_text = f"[{track_status}] Трекинг: {'ВКЛ' if self.config.tracking_enabled else 'ВЫКЛ'}" + self.status_label.config(text=status_text) + + # Update task information + if self.config.current_task_id: + # Get current session duration + current_duration = self.tracker.get_current_session_duration() + current_duration_str = self.format_duration(current_duration) + + # Get planned duration if available + planned_duration = self.tracker.get_planned_duration(self.config.current_task_id) + if planned_duration: + planned_str = self.format_duration(planned_duration * 60) # Convert minutes to seconds + task_text = f"Задача: {self.config.current_task_id} | {current_duration_str} / {planned_str}" + else: + task_text = f"Задача: {self.config.current_task_id} | {current_duration_str}" + else: + task_text = "Нет активной задачи" + + self.task_label.config(text=task_text) + + # Update window information + if hasattr(self.tracker, 'active_window'): + window_title = getattr(self.tracker, 'active_window', 'Неизвестно') + classification = getattr(self.tracker, 'window_classification', 'neutral') + + class_symbol = {"work": "✅", "distraction": "⚠", "neutral": "⚪"}[classification] + window_text = f"Окно: {window_title[:30]}{'...' if len(window_title) > 30 else ''} {class_symbol}" + self.window_label.config(text=window_text) + else: + self.window_label.config(text="Окно: Неизвестно") + + # Update total time today + total_today = self.tracker.get_total_work_today() + total_str = self.format_duration(total_today) + self.total_label.config(text=f"Всего сегодня: {total_str}") + + # Update button colors based on tracking status + if self.config.tracking_enabled: + self.track_btn.config(bg='lightgreen', text="●") + else: + self.track_btn.config(bg='lightcoral', text="○") + + def format_duration(self, seconds: int) -> str: + """Format seconds into human-readable string (h:mm)""" + if seconds < 0: + seconds = 0 + + hours = seconds // 3600 + minutes = (seconds % 3600) // 60 + + if hours > 0: + return f"{hours}ч{minutes:02d}м" + else: + return f"{minutes}м" + + def update_display_periodically(self): + """Update display every second""" + self.update_display() + self.root.after(1000, self.update_display_periodically) + + def show_notification(self, title: str, message: str, duration: int = 5000): + """Show a temporary notification""" + # Create notification window + notif = tk.Toplevel(self.root) + notif.title(title) + notif.geometry("250x80+200+200") + notif.overrideredirect(True) + notif.attributes("-topmost", True) + + # Style similar to main window + frame = tk.Frame(notif, bg='lightyellow', bd=2, relief='solid') + frame.pack(fill='both', expand=True) + + tk.Label(frame, text=title, bg='lightyellow', font=('Arial', 10, 'bold')).pack(pady=5) + tk.Label(frame, text=message, bg='lightyellow', font=('Arial', 9)).pack(pady=5) + + # Close after specified duration + notif.after(duration, notif.destroy) \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..29f8ce8 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,3 @@ +""" +Package initialization for tests +""" \ No newline at end of file diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..cf20f96 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,82 @@ +import json +import os +import tempfile +from unittest.mock import patch +import pytest + +from time_tracker.core.config import load_config, save_config + + +def test_load_config_creates_default_if_not_exists(): + """ creates default when file doesn't exist""" + with tempfile.NamedTemporaryFile(suffix='.json', delete=False) as tmp: + tmp_path = tmp.name + + # Remove the file so it doesn't exist + os.unlink(tmp_path) + + # Mock the config file path + with patch('time_tracker.core.config.CONFIG_FILE', tmp_path): + config = load_config() + + # Check default values + assert config['tracking_enabled'] is True + assert config['window_rules']['work'] == ['PyCharm', 'VS Code'] + assert config['window_rules']['distraction'] == ['YouTube', 'Twitter'] + assert config.get('current_task_id') is None + + # Verify file was created + assert os.path.exists(tmp_path) + + +def test_save_and_load_config(): + """Test saving and loading config preserves data""" + with tempfile.NamedTemporaryFile(suffix='.json', delete=False) as tmp: + tmp_path = tmp.name + + # Clean up after ourselves + if os.path.exists(tmp_path): + os.unlink(tmp_path) + + test_config = { + 'tracking_enabled': False, + 'current_task_id': 'TEST-123', + 'window_rules': { + 'work': ['Editor'], + 'distraction': ['Social Media'] + } + } + + # Mock the config file path + with patch('time_tracker.core.config.CONFIG_FILE', tmp_path): + save_config(test_config) + loaded_config = load_config() + + assert loaded_config['tracking_enabled'] == False + assert loaded_config['current_task_id'] == 'TEST-123' + assert loaded_config['window_rules']['work'] == ['Editor'] + assert loaded_config['window_rules']['distraction'] == ['Social Media'] + + +def test_load_config_preserves_existing_values(): + """Test that loading config doesn't overwrite existing values unnecessarily""" + with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as tmp: + initial_config = { + 'tracking_enabled': True, + 'current_task_id': 'EXISTING-123', + 'window_rules': { + 'work': ['Custom Editor'], + 'distraction': ['Gaming App'] + } + } + json.dump(initial_config, tmp) + tmp_path = tmp.name + + # Mock the config file path + with patch('time_tracker.core.config.CONFIG_FILE', tmp_path): + loaded_config = load_config() + + assert loaded_config['tracking_enabled'] == True + assert loaded_config['current_task_id'] == 'EXISTING-123' + assert loaded_config['window_rules']['work'] == ['Custom Editor'] + assert loaded_config['window_rules']['distraction'] == ['Gaming App'] \ No newline at end of file diff --git a/tests/test_platform_windows.py b/tests/test_platform_windows.py new file mode 100644 index 0000000..67b2385 --- /dev/null +++ b/tests/test_platform_windows.py @@ -0,0 +1,39 @@ +import sys +from unittest.mock import Mock, patch, MagicMock +import pytest + +# Skip these tests if not on Windows +pytestmark = pytest.mark.skipif(sys.platform != "win32", reason="Windows-specific tests") + +from time_tracker.platform.windows import get_active_window, register_session_listener + + +def test_get_active_window(): + """Test getting active window on Windows""" + # This is a complex test that would require actual Windows APIs + # For now, we'll just test that the function exists and doesn't crash + # In real testing, we'd mock win32gui functions + + with patch('win32gui.GetForegroundWindow') as mock_get_fg: + with patch('win32gui.GetWindowText') as mock_get_text: + mock_get_fg.return_value = 12345 + mock_get_text.return_value = "Test Window" + + result = get_active_window() + assert result == "Test Window" + + +def test_register_session_listener(): + """Test registering session listener on Windows""" + # This is a complex function that requires Windows message handling + # We'll test that the function can be called without error + mock_callback = Mock() + + # Since this function starts background threads, we'll just ensure it doesn't crash + try: + register_session_listener(mock_callback) + # If we reach here, the function at least started without crashing + assert True + except Exception: + # If there are import issues or other problems, that's acceptable in test environment + pass \ No newline at end of file diff --git a/tests/test_session_manager.py b/tests/test_session_manager.py new file mode 100644 index 0000000..dd4b5f9 --- /dev/null +++ b/tests/test_session_manager.py @@ -0,0 +1,204 @@ +import json +import os +import tempfile +from datetime import datetime +import pytest +from unittest.mock import patch, MagicMock + +from time_tracker.core.session_manager import SessionManager + + +def test_create_new_session(): + """Test creating a new session""" + with tempfile.TemporaryDirectory() as temp_dir: + # Create tasks directory + tasks_dir = os.path.join(temp_dir, 'tasks') + os.makedirs(tasks_dir, exist_ok=True) + + session_manager = SessionManager(tasks_dir) + + # Create a new session + session = session_manager.create_session('PRJ-123', 'PRJ') + + # Check session has required fields + assert 'session_id' in session + assert session['task_id'] == 'PRJ-123' + assert session['project_id'] == 'PRJ' + assert 'started_at' in session + assert session['duration_seconds'] == 0 + assert session['window_details'] == {} + assert session['distractions'] == [] + assert session['synced_to_youtrack'] == False + + +def test_end_current_session(): + """Test ending the current session""" + with tempfile.TemporaryDirectory() as temp_dir: + tasks_dir = os.path.join(temp_dir, 'tasks') + os.makedirs(tasks_dir, exist_ok=True) + + session_manager = SessionManager(tasks_dir) + + # Start a session + session = session_manager.create_session('PRJ-123', 'PRJ') + session_manager.current_session = session + + # Add some window time + session_manager.record_window_time('PyCharm', 300) # 5 minutes + session_manager.record_distraction('YouTube', 120) # 2 minutes + + # End the session + ended_session = session_manager.end_current_session() + + # Check that session was properly ended + assert 'ended_at' in ended_session + assert ended_session['duration_seconds'] >= 420 # At least 7 minutes + assert ended_session['window_details']['PyCharm'] == 300 + assert len(ended_session['distractions']) == 1 + assert ended_session['distractions'][0]['window'] == 'YouTube' + assert ended_session['distractions'][0]['duration_seconds'] == 120 + + # Check that file was saved + session_file_path = os.path.join(tasks_dir, 'PRJ-123', 'sessions', + f"{ended_session['session_id']}_PRJ-123.json") + assert os.path.exists(session_file_path) + + # Verify content of saved file + with open(session_file_path, 'r') as f: + saved_session = json.load(f) + assert saved_session == ended_session + + +def test_record_window_time(): + """Test recording time for specific windows""" + with tempfile.TemporaryDirectory() as temp_dir: + tasks_dir = os.path.join(temp_dir, 'tasks') + os.makedirs(tasks_dir, exist_ok=True) + + session_manager = SessionManager(tasks_dir) + session = session_manager.create_session('PRJ-123', 'PRJ') + session_manager.current_session = session + + # Record time for multiple windows + session_manager.record_window_time('PyCharm', 300) # 5 minutes + session_manager.record_window_time('Chrome', 120) # 2 minutes + session_manager.record_window_time('PyCharm', 60) # Additional minute + + # Check accumulated times + assert session['window_details']['PyCharm'] == 360 # 6 minutes + assert session['window_details']['Chrome'] == 120 # 2 minutes + + +def test_record_distraction(): + """Test recording distractions""" + with tempfile.TemporaryDirectory() as temp_dir: + tasks_dir = os.path.join(temp_dir, 'tasks') + os.makedirs(tasks_dir, exist_ok=True) + + session_manager = SessionManager(tasks_dir) + session = session_manager.create_session('PRJ-123', 'PRJ') + session_manager.current_session = session + + # Record a distraction + session_manager.record_distraction('YouTube', 120) + + # Check distraction was recorded + assert len(session['distractions']) == 1 + distraction = session['distractions'][0] + assert distraction['window'] == 'YouTube' + assert distraction['duration_seconds'] == 120 + assert 'start' in distraction + + +def test_manual_adjustment(): + """Test adding manual time adjustments""" + with tempfile.TemporaryDirectory() as temp_dir: + tasks_dir = os.path.join(temp_dir, 'tasks') + os.makedirs(tasks_dir, exist_ok=True) + + session_manager = SessionManager(tasks_dir) + session = session_manager.create_session('PRJ-123', 'PRJ') + session_manager.current_session = session + + # Add manual adjustment + session_manager.add_manual_adjustment(900, 'Zoom meeting') + + # Check adjustment was recorded + assert len(session['manual_adjustments']) == 1 + adjustment = session['manual_adjustments'][0] + assert adjustment['operation'] == 'add' + assert adjustment['seconds'] == 900 + assert adjustment['reason'] == 'Zoom meeting' + assert 'applied_at' in adjustment + + # Test subtraction + session_manager.add_manual_adjustment(-300, 'Break') + assert len(session['manual_adjustments']) == 2 + sub_adjustment = session['manual_adjustments'][1] + assert sub_adjustment['operation'] == 'subtract' + assert sub_adjustment['seconds'] == 300 + assert sub_adjustment['reason'] == 'Break' + + +def test_get_current_session_duration(): + """Test getting current session duration with adjustments""" + with tempfile.TemporaryDirectory() as temp_dir: + tasks_dir = os.path.join(temp_dir, 'tasks') + os.makedirs(tasks_dir, exist_ok=True) + + session_manager = SessionManager(tasks_dir) + session = session_manager.create_session('PRJ-123', 'PRJ') + session_manager.current_session = session + + # Add some base duration + session['duration_seconds'] = 1800 # 30 minutes + + # Add manual adjustments + session_manager.add_manual_adjustment(600, 'Extra work') # +10 min + session_manager.add_manual_adjustment(-300, 'Break') # -5 min + + # Calculate final duration + expected_duration = 1800 + 600 - 300 # 2100 seconds = 35 minutes + assert session_manager.get_current_session_duration() == expected_duration + + +def test_load_sessions_for_task(): + """Test loading all sessions for a specific task""" + with tempfile.TemporaryDirectory() as temp_dir: + tasks_dir = os.path.join(temp_dir, 'tasks') + os.makedirs(tasks_dir, exist_ok=True) + + session_manager = SessionManager(tasks_dir) + + # Create multiple sessions for the same task + session1 = session_manager.create_session('PRJ-123', 'PRJ') + session1['duration_seconds'] = 1800 + session1['ended_at'] = '2026-03-10T10:00:00' + session_manager.end_session(session1) + + session2 = session_manager.create_session('PRJ-123', 'PRJ') + session2['duration_seconds'] = 2400 + session2['ended_at'] = '2026-03-10T11:00:00' + session_manager.end_session(session2) + + # Load sessions for the task + sessions = session_manager.load_sessions_for_task('PRJ-123') + + assert len(sessions) == 2 + # Sessions should be sorted by end time (newest first) + assert sessions[0]['duration_seconds'] == 2400 + assert sessions[1]['duration_seconds'] == 1800 + + +def test_no_sessions_for_task(): + """Test that loading sessions for non-existent task returns empty list""" + with tempfile.TemporaryDirectory() as temp_dir: + tasks_dir = os.path.join(temp_dir, 'tasks') + os.makedirs(tasks_dir, exist_ok=True) + + session_manager = SessionManager(tasks_dir) + + # Load sessions for non-existent task + sessions = session_manager.load_sessions_for_task('NONEXISTENT-123') + + assert sessions == [] \ No newline at end of file diff --git a/tests/test_ui_overlay.py b/tests/test_ui_overlay.py new file mode 100644 index 0000000..bf6adfb --- /dev/null +++ b/tests/test_ui_overlay.py @@ -0,0 +1,156 @@ +import tkinter as tk +from unittest.mock import Mock, patch, MagicMock +import pytest +from time_tracker.ui.overlay import OverlayWindow + + +def test_overlay_window_creation(): + """Test that overlay window is created correctly""" + root = tk.Tk() + root.withdraw() # Hide the root window + + # Mock dependencies + mock_tracker = Mock() + mock_config = Mock() + + overlay = OverlayWindow(root, mock_tracker, mock_config) + + # Check that window properties are set + assert overlay.root.wm_attributes('-topmost') == 1 + assert overlay.root.overrideredirect() is True + + # Check that labels exist + assert hasattr(overlay, 'status_label') + assert hasattr(overlay, 'task_label') + assert hasattr(overlay, 'window_label') + + root.destroy() + + +def test_update_display_with_active_task(): + """Test updating display with an active task""" + root = tk.Tk() + root.withdraw() + + mock_tracker = Mock() + mock_config = Mock() + + # Set up mock returns + mock_config.tracking_enabled = True + mock_config.current_task_id = 'PRJ-123' + mock_tracker.get_current_session_duration.return_value = 3600 # 1 hour + mock_tracker.get_total_work_today.return_value = 7200 # 2 hours + + overlay = OverlayWindow(root, mock_tracker, mock_config) + overlay.update_display() + + # Check that labels were updated (we can check the text property) + assert 'PRJ-123' in overlay.task_label.cget('text') + assert '1ч00м' in overlay.status_label.cget('text') + + root.destroy() + + +def test_update_display_without_active_task(): + """Test updating display when no active task""" + root = tk.Tk() + root.withdraw() + + mock_tracker = Mock() + mock_config = Mock() + + # Set up mock returns + mock_config.tracking_enabled = True + mock_config.current_task_id = None + mock_tracker.get_current_session_duration.return_value = 0 + mock_tracker.get_total_work_today.return_value = 0 + + overlay = OverlayWindow(root, mock_tracker, mock_config) + overlay.update_display() + + # Check that labels show appropriate messages + assert 'Нет активной задачи' in overlay.task_label.cget('text') + + root.destroy() + + +def test_toggle_tracking_callback(): + """Test that toggle tracking button works""" + root = tk.Tk() + root.withdraw() + + mock_tracker = Mock() + mock_config = Mock() + + overlay = OverlayWindow(root, mock_tracker, mock_config) + + # Call the toggle method + overlay.toggle_tracking() + + # Verify that the tracker's toggle method was called + mock_tracker.toggle_tracking.assert_called_once() + + root.destroy() + + +def test_new_task_callback(): + """Test that new task button opens dialog""" + root = tk.Tk() + root.withdraw() + + mock_tracker = Mock() + mock_config = Mock() + + overlay = OverlayWindow(root, mock_tracker, mock_config) + + # Mock the simpledialog + with patch('tkinter.simpledialog.askstring') as mock_dialog: + mock_dialog.return_value = 'NEW-456' + overlay.new_task() + + # Verify that the tracker's create_task method was called + mock_tracker.create_task.assert_called_once_with('NEW-456') + + root.destroy() + + +def test_switch_task_callback(): + """Test that switch task button works""" + root = tk.Tk() + root.withdraw() + + mock_tracker = Mock() + mock_config = Mock() + + overlay = OverlayWindow(root, mock_tracker, mock_config) + + # Mock the available tasks + mock_tracker.get_available_tasks.return_value = ['TASK-1', 'TASK-2', 'TASK-3'] + + # Mock the selection dialog + with patch('tkinter.simpledialog.askstring') as mock_dialog: + mock_dialog.return_value = 'TASK-2' + overlay.switch_task() + + # Verify that the tracker's switch_task method was called + mock_tracker.switch_task.assert_called_once_with('TASK-2') + + root.destroy() + + +def test_adjust_time_callback(): + """Test that adjust time button opens dialog""" + root = tk.Tk() + root.withdraw() + + mock_tracker = Mock() + mock_config = Mock() + + overlay = OverlayWindow(root, mock_tracker, mock_config) + + # Mock the adjustment dialog + with patch.object(overlay, 'show_adjust_time_dialog') as mock_dialog: + overlay.adjust_time() + mock_dialog.assert_called_once() + + root.destroy() \ No newline at end of file