initial
This commit is contained in:
8
.idea/.gitignore
generated
vendored
Normal file
8
.idea/.gitignore
generated
vendored
Normal file
@@ -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
|
||||||
11
.idea/TimeTracker.iml
generated
Normal file
11
.idea/TimeTracker.iml
generated
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="PYTHON_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager">
|
||||||
|
<content url="file://$MODULE_DIR$">
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/.venv" />
|
||||||
|
</content>
|
||||||
|
<orderEntry type="jdk" jdkName="Python 3.12 (TimeTracker)" jdkType="Python SDK" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
6
.idea/inspectionProfiles/profiles_settings.xml
generated
Normal file
6
.idea/inspectionProfiles/profiles_settings.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<component name="InspectionProjectProfileManager">
|
||||||
|
<settings>
|
||||||
|
<option name="USE_PROJECT_PROFILE" value="false" />
|
||||||
|
<version value="1.0" />
|
||||||
|
</settings>
|
||||||
|
</component>
|
||||||
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/TimeTracker.iml" filepath="$PROJECT_DIR$/.idea/TimeTracker.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
36
README.md
Normal file
36
README.md
Normal file
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
32
pyproject.toml
Normal file
32
pyproject.toml
Normal file
@@ -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_*"]
|
||||||
1
src/time_tracker/__init__.py
Normal file
1
src/time_tracker/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Time Tracker package."""
|
||||||
33
src/time_tracker/core/config.py
Normal file
33
src/time_tracker/core/config.py
Normal file
@@ -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)
|
||||||
221
src/time_tracker/core/session_manager.py
Normal file
221
src/time_tracker/core/session_manager.py
Normal file
@@ -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
|
||||||
319
src/time_tracker/main.py
Normal file
319
src/time_tracker/main.py
Normal file
@@ -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()
|
||||||
19
src/time_tracker/platform/base.py
Normal file
19
src/time_tracker/platform/base.py
Normal file
@@ -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
|
||||||
75
src/time_tracker/platform/windows.py
Normal file
75
src/time_tracker/platform/windows.py
Normal file
@@ -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
|
||||||
261
src/time_tracker/ui/overlay.py
Normal file
261
src/time_tracker/ui/overlay.py
Normal file
@@ -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("<Button-1>", start_move)
|
||||||
|
self.status_label.bind("<B1-Motion>", do_move)
|
||||||
|
|
||||||
|
# Also bind to other labels for full coverage
|
||||||
|
for widget in [self.task_label, self.window_label, self.total_label]:
|
||||||
|
widget.bind("<Button-1>", start_move)
|
||||||
|
widget.bind("<B1-Motion>", 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)
|
||||||
3
tests/__init__.py
Normal file
3
tests/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""
|
||||||
|
Package initialization for tests
|
||||||
|
"""
|
||||||
82
tests/test_config.py
Normal file
82
tests/test_config.py
Normal file
@@ -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']
|
||||||
39
tests/test_platform_windows.py
Normal file
39
tests/test_platform_windows.py
Normal file
@@ -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
|
||||||
204
tests/test_session_manager.py
Normal file
204
tests/test_session_manager.py
Normal file
@@ -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 == []
|
||||||
156
tests/test_ui_overlay.py
Normal file
156
tests/test_ui_overlay.py
Normal file
@@ -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()
|
||||||
Reference in New Issue
Block a user