[AI] MVP 1

This commit is contained in:
Mikan
2026-03-10 02:20:51 +03:00
parent 6946b8fb50
commit 07ed356cf1
6 changed files with 387 additions and 35 deletions

View File

@@ -5,7 +5,9 @@ from .app import Application
def main(): def main():
try: try:
app = Application() app = Application()
app.run() from .ui.overlay import OverlayWindow
overlay = OverlayWindow(app)
overlay.run()
except RuntimeError as e: except RuntimeError as e:
print(f"Ошибка: {e}", file=sys.stderr) print(f"Ошибка: {e}", file=sys.stderr)
input("Нажмите Enter для выхода...") input("Нажмите Enter для выхода...")

View File

@@ -1,26 +1,218 @@
import json import json
import time
import threading
from pathlib import Path from pathlib import Path
from typing import Optional from datetime import datetime
from .models import TrackerConfig, TrackedTask from typing import Optional, List
from pynput import mouse, keyboard
from .models import TrackerConfig, TrackedTask, WorkDetail, WorkSession
from .utils import get_platform, ensure_dir from .utils import get_platform, ensure_dir
from .ui.overlay import OverlayWindow
class Application: class Application:
def __init__(self): def __init__(self):
self._lock = threading.Lock()
self.config_path = Path("config.json") self.config_path = Path("config.json")
self.tasks_dir = Path("tasks") self.tasks_dir = Path("tasks")
self.config: TrackerConfig = self._load_or_create_config() self.config: TrackerConfig = self._load_or_create_config()
self.current_task: Optional[TrackedTask] = None self.current_task: Optional[TrackedTask] = None
self.platform = get_platform() self.platform = get_platform()
# Загружаем текущую задачу, если указан ID # Состояние трекинга
self.tracking_enabled = self.config.tracking_enabled
self.last_activity: float = time.time() # timestamp
self.last_window_group: Optional[str] = None
self.is_idle: bool = False
self._tracking_thread: Optional[threading.Thread] = None
self._running = True
# Загружаем задачу
if self.config.current_task_id: if self.config.current_task_id:
self.current_task = self._load_task(self.config.current_task_id) self.current_task = self._load_task(self.config.current_task_id)
# Создаём директорию для задач, если нужно
ensure_dir(self.tasks_dir) ensure_dir(self.tasks_dir)
# Запускаем слушатели активности (только если tracking_enabled)
self._start_activity_listeners()
# Запускаем фоновый трекер
self._start_tracking_loop()
def _start_activity_listeners(self):
def on_activity(*_):
with self._lock:
self.last_activity = time.time()
self.is_idle = False
self.mouse_listener = mouse.Listener(on_move=on_activity, on_click=on_activity)
self.keyboard_listener = keyboard.Listener(on_press=on_activity)
self.mouse_listener.start()
self.keyboard_listener.start()
def _start_tracking_loop(self):
def loop():
while self._running:
time.sleep(5)
self._check_and_update_tracking()
self._tracking_thread = threading.Thread(target=loop, daemon=True)
self._tracking_thread.start()
def _check_and_update_tracking(self):
with self._lock:
if not self.tracking_enabled or not self.current_task:
return
# Проверка: спит ли система? (упрощённо — если активность > 60 сек назад)
current_time = time.time()
if current_time - self.last_activity > 60:
self.is_idle = True
self.last_window_group = None
return
# Получаем текущее окно
window_info = self.platform.get_active_window()
if not window_info.title and not window_info.process_name:
self.is_idle = True
return
# Определяем группу по политике
group_name = self._match_window_to_policy(window_info)
is_blocked = self._is_group_blocked(group_name)
if is_blocked:
self.is_idle = True
self.last_window_group = None
return
# Если рабочая группа — накапливаем время
self.is_idle = False
if self.last_window_group != group_name:
# Новая группа — завершаем предыдущую сессию (если была)
self.last_window_group = group_name
# Добавляем 5 секунд к текущей группе
self._add_work_detail(group_name, 5.0)
def _match_window_to_policy(self, window_info) -> str:
title = window_info.title.lower()
proc = window_info.process_name.lower()
for policy in self.config.window_policies:
for pattern in policy.window_patterns:
if pattern.lower() in title:
return policy.group_name
for pname in policy.process_names:
if pname.lower() == proc:
return policy.group_name
return "Неизвестное приложение"
def _is_group_blocked(self, group_name: str) -> bool:
for policy in self.config.window_policies:
if policy.group_name == group_name:
return policy.policy_type == "blocked"
return False # по умолчанию — не блокируем
def _add_work_detail(self, group_name: str, seconds: float):
if not self.current_task:
return
# Создаём новую сессию или используем последнюю
if not self.current_task.sessions:
session = WorkSession(
start_time=datetime.now(),
end_time=datetime.now(),
details=[],
synchronized=False
)
self.current_task.sessions.append(session)
else:
session = self.current_task.sessions[-1]
# Ищем detail с такой же группой
for detail in session.details:
if detail.group_name == group_name:
detail.duration_seconds += seconds
break
else:
session.details.append(WorkDetail(group_name=group_name, duration_seconds=seconds))
# Обновляем время сессии
session.end_time = datetime.now()
# === Публичные методы с локом ===
def toggle_tracking(self):
with self._lock:
if not self.current_task:
self.tracking_enabled = False
return
self.tracking_enabled = not self.tracking_enabled
self.config.tracking_enabled = self.tracking_enabled
self._save_config(self.config)
def create_task(self, task_id: str, project_id: Optional[str], estimated_minutes: Optional[float]):
with self._lock:
ensure_dir(self.tasks_dir)
new_task = TrackedTask(
task_id=task_id,
project_id=project_id,
estimated_seconds=estimated_minutes * 60 if estimated_minutes else None,
start_time=datetime.now()
)
self.save_task(new_task)
self.current_task = new_task
self.config.current_task_id = task_id
self.config.tracking_enabled = True
self.tracking_enabled = True
self._save_config(self.config)
def select_task(self, task_id: str):
with self._lock:
task = self._load_task(task_id)
if task:
self.current_task = task
self.config.current_task_id = task_id
self.config.tracking_enabled = True
self.tracking_enabled = True
self._save_config(self.config)
def add_manual_time(self, minutes: float):
with self._lock:
if not self.current_task:
return
seconds = abs(minutes) * 60
sign = 1 if minutes >= 0 else -1
self._add_work_detail("Manual", sign * seconds)
def get_display_status(self) -> str:
with self._lock:
if not self.tracking_enabled:
return "Пауза"
if not self.current_task:
return "Пауза"
if self.is_idle:
return "Бездействие"
return "Активная работа"
def get_current_window_info(self) -> dict:
with self._lock:
if not self.tracking_enabled or not self.current_task:
return {"group": "Пауза", "detail": "Трекинг остановлен. Нажмите «Пауза» для запуска."}
if self.is_idle:
return {"group": "Бездействие", "detail": "Нет активности более 60 сек или отвлекающее окно"}
window_info = self.platform.get_active_window()
group = self._match_window_to_policy(window_info)
return {
"group": group,
"detail": f"{window_info.title} | {window_info.process_name}"
}
# === Остальные методы (без изменений, но с локом где нужно) ===
def _load_or_create_config(self) -> TrackerConfig: def _load_or_create_config(self) -> TrackerConfig:
if self.config_path.exists(): if self.config_path.exists():
with open(self.config_path, "r", encoding="utf-8") as f: with open(self.config_path, "r", encoding="utf-8") as f:
@@ -51,8 +243,9 @@ class Application:
with open(task_file, "w", encoding="utf-8") as f: with open(task_file, "w", encoding="utf-8") as f:
f.write(task.model_dump_json(indent=2)) f.write(task.model_dump_json(indent=2))
def run(self): def shutdown(self):
# Пока просто запускаем UI self._running = False
# Позже сюда добавим трекинг в фоне if hasattr(self, 'mouse_listener'):
overlay = OverlayWindow() self.mouse_listener.stop()
overlay.run() if hasattr(self, 'keyboard_listener'):
self.keyboard_listener.stop()

View File

@@ -1,9 +1,18 @@
import tkinter as tk import tkinter as tk
from datetime import datetime
from tkinter import Toplevel, Label, Entry, Button from tkinter import Toplevel, Label, Entry, Button
from typing import Callable, TYPE_CHECKING
if TYPE_CHECKING:
from ..app import Application
class AdjustTimeWindow: class AdjustTimeWindow:
def __init__(self, parent): def __init__(self, parent, app: "Application", on_close_callback: Callable):
self.parent = parent
self.app = app
self.on_close_callback = on_close_callback
self.window = Toplevel(parent) self.window = Toplevel(parent)
self.window.title("Изменить время") self.window.title("Изменить время")
self.window.geometry("250x120") self.window.geometry("250x120")
@@ -20,5 +29,39 @@ class AdjustTimeWindow:
Button(self.window, text="Применить", command=self.on_apply).pack(pady=5) Button(self.window, text="Применить", command=self.on_apply).pack(pady=5)
def on_apply(self): def on_apply(self):
# Позже: применить изменение if not self.app.current_task:
self.window.destroy()
return
try:
minutes = float(self.minutes_entry.get())
except ValueError:
return
if minutes == 0:
self.window.destroy()
return
seconds = abs(minutes * 60)
# Создаём новую сессию или добавляем в последнюю?
# Для простоты — добавим новую сессию с одним WorkDetail
from ..models import WorkDetail, WorkSession
detail = WorkDetail(
group_name="Manual",
duration_seconds=seconds if minutes > 0 else -seconds # можно хранить отрицательное
)
session = WorkSession(
start_time=datetime.now(),
end_time=datetime.now(),
details=[detail],
synchronized=False
)
self.app.current_task.sessions.append(session)
self.app.save_task(self.app.current_task)
self.on_close_callback()
self.window.destroy() self.window.destroy()

View File

@@ -1,14 +1,23 @@
import tkinter as tk import tkinter as tk
from tkinter import Toplevel, Label, Entry, Button, Frame from datetime import datetime
from tkinter import Toplevel, Label, Entry, Button
from typing import Callable, TYPE_CHECKING
if TYPE_CHECKING:
from ..app import Application
class NewTaskWindow: class NewTaskWindow:
def __init__(self, parent): def __init__(self, parent, app: "Application", on_close_callback: Callable):
self.parent = parent
self.app = app
self.on_close_callback = on_close_callback
self.window = Toplevel(parent) self.window = Toplevel(parent)
self.window.title("Новая задача") self.window.title("Новая задача")
self.window.geometry("300x200") self.window.geometry("300x200")
self.window.transient(parent) self.window.transient(parent)
self.window.grab_set() # модальное self.window.grab_set()
Label(self.window, text="ID задачи:").pack(pady=(10, 0)) Label(self.window, text="ID задачи:").pack(pady=(10, 0))
self.task_id_entry = Entry(self.window) self.task_id_entry = Entry(self.window)
@@ -25,5 +34,36 @@ class NewTaskWindow:
Button(self.window, text="Создать", command=self.on_create).pack(pady=10) Button(self.window, text="Создать", command=self.on_create).pack(pady=10)
def on_create(self): def on_create(self):
# Позже: сохранить задачу task_id = self.task_id_entry.get().strip()
if not task_id:
return
try:
estimate_str = self.estimate_entry.get().strip()
estimate_seconds = float(estimate_str) * 60 if estimate_str else None
except ValueError:
return
project_id = self.project_id_entry.get().strip() or None
# Создаём новую задачу
from ..models import TrackedTask
new_task = TrackedTask(
task_id=task_id,
project_id=project_id,
estimated_seconds=estimate_seconds,
start_time=datetime.now()
)
# Сохраняем
self.app.save_task(new_task)
# Обновляем конфиг
self.app.config.current_task_id = task_id
self.app._save_config(self.app.config)
# Обновляем текущую задачу в памяти
self.app.current_task = new_task
self.on_close_callback()
self.window.destroy() self.window.destroy()

View File

@@ -1,10 +1,14 @@
import tkinter as tk import tkinter as tk
from tkinter import Menu, Label, Button, Frame from tkinter import Menu, Label, Button, Frame
from typing import Optional from typing import TYPE_CHECKING
if TYPE_CHECKING:
from ..app import Application
class OverlayWindow: class OverlayWindow:
def __init__(self): def __init__(self, app: "Application"):
self.app = app
self.root = tk.Tk() self.root = tk.Tk()
self.root.title("Citrus Time Tracker") self.root.title("Citrus Time Tracker")
self.root.geometry("320x180") self.root.geometry("320x180")
@@ -30,6 +34,8 @@ class OverlayWindow:
# Содержимое # Содержимое
self._build_ui() self._build_ui()
self._auto_update()
def center_window(self): def center_window(self):
self.root.update_idletasks() self.root.update_idletasks()
screen_width = self.root.winfo_screenwidth() screen_width = self.root.winfo_screenwidth()
@@ -66,27 +72,72 @@ class OverlayWindow:
btn_frame = Frame(main_frame) btn_frame = Frame(main_frame)
btn_frame.pack(side="bottom", fill="x", pady=(10, 0)) btn_frame.pack(side="bottom", fill="x", pady=(10, 0))
Button(btn_frame, text="⏸ Пауза", command=self.on_pause).pack(side="left", fill="x", expand=True, padx=(0, 2)) self.pause_button = Button(btn_frame, text="⏸ Пауза", command=self.on_pause)
Button(btn_frame, text=" Новая", command=self.on_new_task).pack(side="left", fill="x", expand=True, padx=2) self.pause_button.pack(side="left", fill="x", expand=True, padx=(0, 2))
Button(btn_frame, text="🔍 Выбрать", command=self.on_select_task).pack(side="left", fill="x", expand=True, padx=2) self.new_task_btn = Button(btn_frame, text=" Новая", command=self.on_new_task)
Button(btn_frame, text="✏️ Время", command=self.on_adjust_time).pack(side="left", fill="x", expand=True, padx=(2, 0)) self.new_task_btn.pack(side="left", fill="x", expand=True, padx=2)
self.select_task_btn = Button(btn_frame, text="🔍 Выбрать", command=self.on_select_task)
self.select_task_btn.pack(side="left", fill="x", expand=True, padx=2)
self.adjust_time_btn = Button(btn_frame, text="✏️ Время", command=self.on_adjust_time)
self.adjust_time_btn.pack(side="left", fill="x", expand=True, padx=(2, 0))
self._update_display()
def _update_display(self):
status = self.app.get_display_status()
self.status_label.config(text=f"Citrus Time Tracker: {status}")
task_id = self.app.config.current_task_id or "<Не выбрано>"
self.task_label.config(text=f"Задача: {task_id}")
# Время
if self.app.current_task:
worked = self.app.current_task.total_worked_seconds / 60
est = self.app.current_task.estimated_seconds
if est is not None:
est_min = est / 60
rem = self.app.current_task.remaining_seconds / 60
time_info = f"Затрачено: {worked:.1f} мин | Оценка: {est_min:.1f} мин"
else:
time_info = f"Затрачено: {worked:.1f} мин | Оценка: —"
else:
time_info = "Задача не выбрана"
self.time_info_label.config(text=time_info)
# Окно
win_info = self.app.get_current_window_info()
self.window_group_label.config(text=win_info["group"])
self.window_detail_label.config(text=win_info["detail"])
# Активность кнопок
has_task = self.app.current_task is not None
self.pause_button.config(state="normal" if has_task else "disabled")
for btn in [self.new_task_btn, self.select_task_btn, self.adjust_time_btn]:
btn.config(state="normal")
# Но "Добавить время" — только если есть задача
self.adjust_time_btn.config(state="normal" if has_task else "disabled")
def _auto_update(self):
self._update_display()
self.root.after(2000, self._auto_update) # обновляем каждые 2 сек
# === Обработчики событий (пока без логики, только открытие окон) === # === Обработчики событий (пока без логики, только открытие окон) ===
def on_pause(self): def on_pause(self):
pass # будет реализовано позже self.app.toggle_tracking()
def on_new_task(self): def on_new_task(self):
from .new_task_window import NewTaskWindow from .new_task_window import NewTaskWindow
NewTaskWindow(self.root) NewTaskWindow(self.root, self.app, self._update_display)
def on_select_task(self): def on_select_task(self):
from .select_task_window import SelectTaskWindow from .select_task_window import SelectTaskWindow
SelectTaskWindow(self.root) SelectTaskWindow(self.root, self.app, self._update_display)
def on_adjust_time(self): def on_adjust_time(self):
from .adjust_time_window import AdjustTimeWindow from .adjust_time_window import AdjustTimeWindow
AdjustTimeWindow(self.root) AdjustTimeWindow(self.root, self.app, self._update_display)
# === UI-управление === # === UI-управление ===

View File

@@ -1,32 +1,55 @@
import tkinter as tk import tkinter as tk
from tkinter import Toplevel, Listbox, Scrollbar, Button, Frame from pathlib import Path
from tkinter import Toplevel, Listbox, Scrollbar, Button
from typing import Callable, TYPE_CHECKING
if TYPE_CHECKING:
from ..app import Application
class SelectTaskWindow: class SelectTaskWindow:
def __init__(self, parent): def __init__(self, parent, app: "Application", on_close_callback: Callable):
self.parent = parent
self.app = app
self.on_close_callback = on_close_callback
self.window = Toplevel(parent) self.window = Toplevel(parent)
self.window.title("Выбрать задачу") self.window.title("Выбрать задачу")
self.window.geometry("300x250") self.window.geometry("300x250")
self.window.transient(parent) self.window.transient(parent)
self.window.grab_set() self.window.grab_set()
list_frame = Frame(self.window) # Сканируем папку tasks/
task_files = list(Path("tasks").glob("*.json")) if Path("tasks").exists() else []
self.task_ids = [f.stem for f in task_files]
list_frame = tk.Frame(self.window)
list_frame.pack(fill="both", expand=True, padx=10, pady=10) list_frame.pack(fill="both", expand=True, padx=10, pady=10)
scrollbar = Scrollbar(list_frame) scrollbar = Scrollbar(list_frame)
scrollbar.pack(side="right", fill="y") scrollbar.pack(side="right", fill="y")
self.listbox = Listbox(list_frame, yscrollcommand=scrollbar.set) self.listbox = Listbox(list_frame, yscrollcommand=scrollbar.set)
for tid in self.task_ids:
self.listbox.insert("end", tid)
self.listbox.pack(side="left", fill="both", expand=True) self.listbox.pack(side="left", fill="both", expand=True)
# Заглушка: список задач
for i in range(5):
self.listbox.insert("end", f"TASK-{i+1} (1 ч 20 мин)")
scrollbar.config(command=self.listbox.yview) scrollbar.config(command=self.listbox.yview)
Button(self.window, text="Выбрать", command=self.on_select).pack(pady=5) Button(self.window, text="Выбрать", command=self.on_select).pack(pady=5)
def on_select(self): def on_select(self):
# Позже: выбрать задачу selection = self.listbox.curselection()
if not selection:
return
task_id = self.task_ids[selection[0]]
# Загружаем задачу
task = self.app._load_task(task_id)
if task:
self.app.current_task = task
self.app.config.current_task_id = task_id
self.app._save_config(self.app.config)
self.on_close_callback()
self.window.destroy() self.window.destroy()