Compare commits

...

4 Commits

Author SHA1 Message Date
Mikan
332bc0a150 Changing logic 2026-03-12 19:57:46 +03:00
Mikan
534aa9a7d1 Changing logic 2026-03-12 19:54:31 +03:00
Mikan
0f8a86e536 Changing logic 2026-03-12 19:41:29 +03:00
Mikan
1afa6f71ca Better sync 2026-03-12 18:50:39 +03:00
5 changed files with 366 additions and 48 deletions

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "citrus_time_tracker" name = "citrus_time_tracker"
version = "1.0.0" version = "1.1.0"
description = "Time tracking tool with YouTrack integration" description = "Time tracking tool with YouTrack integration"
readme = "README.md" readme = "README.md"
authors = [{name = "Mikan"}] authors = [{name = "Mikan"}]

View File

@@ -1,4 +1,5 @@
import json import json
import logging
import time import time
import threading import threading
from pathlib import Path from pathlib import Path
@@ -17,6 +18,9 @@ from .youtrack_client import YouTrackClient
if not load_dotenv(".env"): if not load_dotenv(".env"):
load_dotenv(".env.example") load_dotenv(".env.example")
logging.basicConfig(level=logging.WARNING)
logger = logging.getLogger("TimeTracker")
class Application: class Application:
def __init__(self): def __init__(self):
self._lock = threading.Lock() self._lock = threading.Lock()
@@ -34,11 +38,17 @@ class Application:
self._tracking_thread: Optional[threading.Thread] = None self._tracking_thread: Optional[threading.Thread] = None
self._running = True self._running = True
self.session_start_time: Optional[float] = None self.session_start_time: Optional[float] = None
self.idle_start_time: Optional[float] = None
# Загружаем задачу # Загружаем задачу
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)
try:
self.sync_with_youtrack()
except Exception as e:
logger.error(f"Ошибка при начальной синхронизации: {e}")
ensure_dir(self.tasks_dir) ensure_dir(self.tasks_dir)
# Запускаем слушатели активности (только если tracking_enabled) # Запускаем слушатели активности (только если tracking_enabled)
@@ -61,7 +71,7 @@ class Application:
window_info = self.platform.get_active_window() window_info = self.platform.get_active_window()
group_name = self._match_window_to_policy(window_info) group_name = self._match_window_to_policy(window_info)
is_blocked = self._is_group_blocked(group_name) is_blocked = self._is_group_blocked(group_name)
is_unknown = (group_name == "Неизвестное приложение") is_unknown = (group_name == "Unknown app")
# Считаем "рабочим", если не blocked и не unknown # Считаем "рабочим", если не blocked и не unknown
if not is_blocked and not is_unknown: if not is_blocked and not is_unknown:
@@ -82,32 +92,110 @@ class Application:
with self._lock: with self._lock:
if not self.tracking_enabled or not self.current_task: if not self.tracking_enabled or not self.current_task:
self.session_start_time = None self.session_start_time = None
self.idle_start_time = None
return return
current_time = time.time() current_time = time.time()
window_info = self.platform.get_active_window() window_info = self.platform.get_active_window()
group_name = self._match_window_to_policy(window_info) group_name = self._match_window_to_policy(window_info)
is_blocked = self._is_group_blocked(group_name) is_blocked = self._is_group_blocked(group_name)
current_idle = (current_time - self.last_activity > self.config.idle_threshold_seconds) or is_blocked
was_idle = self.is_idle was_idle = self.is_idle
self.is_idle = current_idle
if current_time - self.last_activity > 60 or is_blocked: # === СЛУЧАЙ 1: Переход в бездействие ===
self.is_idle = True if not was_idle and current_idle:
self.last_window_group = None self.idle_start_time = current_time
self.session_start_time = None self.session_start_time = None
else: self.last_window_group = None
self.is_idle = False self.maybe_save_task()
logger.debug(f"Переход в бездействие после {current_time - self.last_activity:.0f} сек")
return
# === СЛУЧАЙ 2: Выход из бездействия ===
if was_idle and not current_idle:
idle_duration = current_time - self.idle_start_time if self.idle_start_time else 0
self.idle_start_time = None
self.session_start_time = current_time
self.last_window_group = group_name
# Создаём НОВУЮ сессию при длительном простое или смене дня
should_create_new = False
reasons = []
if idle_duration > self.config.new_session_after_idle_seconds:
should_create_new = True
reasons.append(f"простой {idle_duration / 60:.1f} мин")
if self.current_task.sessions:
last_date = self.current_task.sessions[-1].end_time.date()
current_date = datetime.now().date()
if last_date < current_date:
should_create_new = True
reasons.append(f"новый день ({last_date}{current_date})")
if not self.current_task.sessions:
should_create_new = True
reasons.append("первая сессия")
if should_create_new:
new_session = WorkSession(
start_time=datetime.now(),
end_time=datetime.now(),
details=[],
synchronized=False
)
self.current_task.sessions.append(new_session)
logger.info(
f"Новая сессия для {self.current_task.task_id}: {', '.join(reasons)}"
)
# Добавляем первую запись в новую/текущую сессию
if self.current_task.sessions:
self._add_work_detail_to_session(self.current_task.sessions[-1], group_name, 5.0)
return
# === СЛУЧАЙ 3: Продолжение активной работы ===
if not current_idle:
if self.last_window_group != group_name: if self.last_window_group != group_name:
self.last_window_group = group_name self.last_window_group = group_name
self._add_work_detail(group_name, 5.0)
# Начинаем/продолжаем сессию # Гарантируем наличие сессии
if not self.current_task.sessions:
self.current_task.sessions.append(WorkSession(
start_time=datetime.now(),
end_time=datetime.now(),
details=[],
synchronized=False
))
self._add_work_detail_to_session(self.current_task.sessions[-1], group_name, 5.0)
if self.session_start_time is None: if self.session_start_time is None:
self.session_start_time = current_time self.session_start_time = current_time
return
def _add_work_detail_to_session(self, session: WorkSession, group_name: str, seconds: float):
"""Добавляет запись в существующую сессию и помечает её как изменённую"""
# Объединяем записи одной группы
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()
# КРИТИЧЕСКИ ВАЖНО: сбрасываем флаг при любом изменении!
if session.synchronized:
session.synchronized = False
logger.debug(
f"Сессия {session.id or 'без ID'} помечена как изменённая "
f"(было: {session.youtrack_duration_minutes or 0:.1f} мин, "
f"стало: {session.total_minutes:.1f} мин)"
)
# Сохраняем при переходе в бездействие
if not was_idle and self.is_idle:
self.maybe_save_task()
def _match_window_to_policy(self, window_info) -> str: def _match_window_to_policy(self, window_info) -> str:
title = window_info.title.lower() title = window_info.title.lower()
@@ -120,7 +208,7 @@ class Application:
for pname in policy.process_names: for pname in policy.process_names:
if pname.lower() == proc: if pname.lower() == proc:
return policy.group_name return policy.group_name
return "Неизвестное приложение" return "Unknown app"
def _is_group_blocked(self, group_name: str) -> bool: def _is_group_blocked(self, group_name: str) -> bool:
for policy in self.config.window_policies: for policy in self.config.window_policies:
@@ -161,10 +249,6 @@ class Application:
"""Сохраняет текущую задачу, если она есть""" """Сохраняет текущую задачу, если она есть"""
if self.current_task: if self.current_task:
try:
self.sync_with_youtrack()
except:
pass
self.save_task(self.current_task) self.save_task(self.current_task)
def toggle_tracking(self): def toggle_tracking(self):
@@ -178,9 +262,17 @@ class Application:
self.config.tracking_enabled = self.tracking_enabled self.config.tracking_enabled = self.tracking_enabled
self._save_config(self.config) self._save_config(self.config)
# Сохраняем при переходе в паузу
if was_enabled and not self.tracking_enabled: if was_enabled and not self.tracking_enabled:
# Переход в паузу → сохраняем и сбрасываем состояние
self.maybe_save_task() self.maybe_save_task()
self.is_idle = True
self.idle_start_time = time.time()
self.session_start_time = None
elif not was_enabled and self.tracking_enabled:
# Возобновление → сбрасываем состояние бездействия
self.is_idle = False
self.idle_start_time = None
self.session_start_time = time.time()
def create_task(self, task_id: str, project_id: Optional[str], estimated_minutes: Optional[float]): def create_task(self, task_id: str, project_id: Optional[str], estimated_minutes: Optional[float]):
with self._lock: with self._lock:
@@ -197,6 +289,10 @@ class Application:
self.config.tracking_enabled = True self.config.tracking_enabled = True
self.tracking_enabled = True self.tracking_enabled = True
self._save_config(self.config) self._save_config(self.config)
try:
self.sync_with_youtrack()
except Exception as e:
logger.error(f"Ошибка при синхронизации после создания задачи: {e}")
def select_task(self, task_id: str): def select_task(self, task_id: str):
with self._lock: with self._lock:
@@ -208,13 +304,45 @@ class Application:
self.tracking_enabled = True self.tracking_enabled = True
self._save_config(self.config) self._save_config(self.config)
try:
self.sync_with_youtrack()
except Exception as e:
logger.error(f"Ошибка при синхронизации после выбора задачи: {e}")
def add_manual_time(self, minutes: float): def add_manual_time(self, minutes: float):
with self._lock: with self._lock:
if not self.current_task: if not self.current_task:
logger.warning("Невозможно добавить время: задача не выбрана")
return return
seconds = abs(minutes) * 60
sign = 1 if minutes >= 0 else -1 if minutes == 0:
self._add_work_detail("Manual", sign * seconds) logger.info("Пропущено добавление 0 минут")
return
# Создаём ОТДЕЛЬНУЮ сессию для ручного времени
sign = "+" if minutes > 0 else "-"
new_session = WorkSession(
start_time=datetime.now(),
end_time=datetime.now(),
details=[WorkDetail(
group_name="Manual Adjustment",
duration_seconds=abs(minutes) * 60
)],
synchronized=False,
description=f"Manual: {sign}{abs(minutes):.1f} min"
)
self.current_task.sessions.append(new_session)
logger.info(
f"Создана ручная сессия: {minutes:+.1f} мин для {self.current_task.task_id} "
f"(всего сессий: {len(self.current_task.sessions)})"
)
# Сохраняем и синхронизируем немедленно
self.save_task(self.current_task)
try:
self.sync_with_youtrack()
except Exception as e:
logger.error(f"Ошибка синхронизации после ручного добавления: {e}")
def get_display_status(self) -> str: def get_display_status(self) -> str:
with self._lock: with self._lock:
@@ -273,9 +401,7 @@ class Application:
return None return None
def save_task(self, task: TrackedTask) -> None: def save_task(self, task: TrackedTask) -> None:
task_file = self.tasks_dir / f"{task.task_id}.json" self._save_task_no_sync(task)
with open(task_file, "w", encoding="utf-8") as f:
f.write(task.model_dump_json(indent=2))
def shutdown(self): def shutdown(self):
self._running = False self._running = False
@@ -286,28 +412,112 @@ class Application:
self.keyboard_listener.stop() self.keyboard_listener.stop()
def sync_with_youtrack(self): def sync_with_youtrack(self):
"""Синхронизирует все несинхронизированные сессии текущей задачи с YouTrack"""
if not self.current_task: if not self.current_task:
return return
client = YouTrackClient() client = YouTrackClient()
if not client.enabled: if not client.enabled:
logger.info("YouTrack: синхронизация отключена (нет настроек в .env)")
return return
# Проверяем, существует ли задача
if not client.issue_exists(self.current_task.task_id): if not client.issue_exists(self.current_task.task_id):
logger.warning(f"YouTrack: задача {self.current_task.task_id} не существует — синхронизация пропущена")
return return
changed = False remote_items = client.get_issue_work_items(self.current_task.task_id)
for session in self.current_task.sessions: if not remote_items:
if not session.synchronized: logger.info(f"YouTrack: нет записей времени для задачи {self.current_task.task_id}")
total_minutes = sum(detail.duration_seconds for detail in session.details) / 60.0
if client.add_work_item(self.current_task.task_id, total_minutes):
session.synchronized = True
changed = True
else:
# Если ошибка — не помечаем как синхронизированную (повторим позже)
pass
if changed: with self._lock:
self.save_task(self.current_task) changed = False
# Шаг 1: Обрабатываем локальные сессии
for session in self.current_task.sessions:
total_minutes = session.total_minutes
if total_minutes <= 0:
session.synchronized = True
session.youtrack_duration_minutes = 0.0
changed = True
continue
# === СЛУЧАЙ A: Сессия с ID — проверяем необходимость обновления ===
if session.id:
# Проверяем изменение ДАЖЕ если помечена как synchronized
needs_update = not session.synchronized or session.is_modified()
if needs_update:
if client.update_work_item(self.current_task.task_id, session.id, total_minutes):
session.synchronized = True
session.youtrack_duration_minutes = total_minutes
changed = True
logger.info(
f"Обновлена сессия {session.id} для {self.current_task.task_id}: "
f"{total_minutes:.1f} мин"
)
else:
logger.warning(f"Не удалось обновить сессию {session.id}")
# else: сессия не изменилась — пропускаем
# === СЛУЧАЙ B: Сессия без ID — создаём новую запись ===
else:
work_item_id = client.add_work_item(
self.current_task.task_id,
total_minutes,
description=session.description or f"Auto: {', '.join(set(d.group_name for d in session.details))}"
)
if work_item_id:
session.id = work_item_id
session.synchronized = True
session.youtrack_duration_minutes = total_minutes
# Пытаемся найти точное совпадение для youtrack_created_at
remote_match = next((r for r in remote_items if r['id'] == work_item_id), None)
if remote_match and remote_match.get('created'):
session.youtrack_created_at = datetime.fromtimestamp(remote_match['created'] / 1000)
changed = True
logger.info(
f"Создана запись {work_item_id} для {self.current_task.task_id}: "
f"{total_minutes:.1f} мин"
)
else:
logger.warning(f"Не удалось создать запись для сессии без ID")
# Шаг 2: Добавляем удалённые записи, которых нет локально
local_ids = {s.id for s in self.current_task.sessions if s.id}
for remote_item in remote_items:
if remote_item['id'] not in local_ids and remote_item['minutes'] > 0:
now = datetime.now()
new_session = WorkSession(
start_time=now,
end_time=now,
details=[WorkDetail(
group_name="YouTrack Manual",
duration_seconds=remote_item['minutes'] * 60
)],
synchronized=True,
id=remote_item['id'],
youtrack_duration_minutes=remote_item['minutes'],
youtrack_created_at=datetime.fromtimestamp(remote_item['created'] / 1000)
if remote_item.get('created') else now,
description=remote_item.get('text')
)
self.current_task.sessions.append(new_session)
changed = True
logger.info(
f"Добавлена удалённая запись {remote_item['id']} "
f"({remote_item['minutes']} мин) в локальную задачу"
)
if changed:
self._save_task_no_sync(self.current_task)
logger.info(
f"Синхронизация завершена для {self.current_task.task_id}: "
f"{len(self.current_task.sessions)} сессий"
)
def _save_task_no_sync(self, task: TrackedTask) -> None:
"""Сохраняет задачу БЕЗ повторной синхронизации (во избежание рекурсии)"""
task_file = self.tasks_dir / f"{task.task_id}.json"
with open(task_file, "w", encoding="utf-8") as f:
f.write(task.model_dump_json(indent=2))

View File

@@ -14,7 +14,25 @@ class WorkSession(BaseModel):
end_time: datetime end_time: datetime
details: List[WorkDetail] details: List[WorkDetail]
synchronized: bool = False synchronized: bool = False
id: Optional[str] = None
youtrack_created_at: Optional[datetime] = None
description: Optional[str] = None
youtrack_duration_minutes: Optional[float] = None
class Config:
json_encoders = {
datetime: lambda v: v.isoformat()
}
@property
def total_minutes(self) -> float:
return sum(d.duration_seconds for d in self.details) / 60.0
def is_modified(self) -> bool:
"""Проверяет, изменилась ли длительность сессии после последней синхронизации"""
if self.youtrack_duration_minutes is None:
return not self.synchronized # Несинхронизированная сессия = изменённая
return abs(self.total_minutes - self.youtrack_duration_minutes) > 0.5 # погрешность 30 сек
class TrackedTask(BaseModel): class TrackedTask(BaseModel):
task_id: str task_id: str
@@ -50,6 +68,8 @@ class TrackerConfig(BaseModel):
tracking_enabled: bool = False tracking_enabled: bool = False
current_task_id: Optional[str] = None current_task_id: Optional[str] = None
window_policies: List[WindowPolicy] = Field(default_factory=list) window_policies: List[WindowPolicy] = Field(default_factory=list)
idle_threshold_seconds: int = 60 # Порог перехода в idle
new_session_after_idle_seconds: int = 600 # 10 минут для новой сессии
@staticmethod @staticmethod
def default_policies() -> List[WindowPolicy]: def default_policies() -> List[WindowPolicy]:
@@ -71,4 +91,16 @@ class TrackerConfig(BaseModel):
def __init__(self, **data): def __init__(self, **data):
super().__init__(**data) super().__init__(**data)
if not self.window_policies: if not self.window_policies:
self.window_policies = self.default_policies() self.window_policies = self.default_policies()
class YouTrackTimeTrackerEntryDuration(BaseModel):
minutes: int
class YouTrackTimeTrackerEntry(BaseModel):
id: str
duration: YouTrackTimeTrackerEntryDuration
@property
def minutes(self) -> int:
return self.duration.minutes

View File

@@ -74,7 +74,7 @@ class OverlayWindow:
self.session_timer_label.pack(pady=(5, 0)) self.session_timer_label.pack(pady=(5, 0))
# Текущее окно # Текущее окно
self.window_group_label = Label(main_frame, text="Неизвестное приложение", anchor="w", fg="gray") self.window_group_label = Label(main_frame, text="Unknown app", anchor="w", fg="gray")
self.window_group_label.pack(anchor="w") self.window_group_label.pack(anchor="w")
self.window_detail_label = Label(main_frame, text="", anchor="w", font=("TkDefaultFont", 8), fg="gray") self.window_detail_label = Label(main_frame, text="", anchor="w", font=("TkDefaultFont", 8), fg="gray")

View File

@@ -1,8 +1,14 @@
import json
from typing import List, Optional, Tuple
from datetime import datetime
import httpx import httpx
import os import os
import logging import logging
from pathlib import Path from pathlib import Path
from citrus_time_tracker.models import YouTrackTimeTrackerEntry
logging.basicConfig(level=logging.WARNING) logging.basicConfig(level=logging.WARNING)
logger = logging.getLogger("YouTrackClient") logger = logging.getLogger("YouTrackClient")
@@ -57,25 +63,95 @@ class YouTrackClient:
logger.error(f"YouTrack: ошибка при проверке задачи {issue_id}: {e}") logger.error(f"YouTrack: ошибка при проверке задачи {issue_id}: {e}")
return False return False
def add_work_item(self, issue_id: str, minutes: float) -> bool: def add_work_item(self, issue_id: str, minutes: float, description: Optional[str] = None) -> Optional[str]:
"""
Создаёт новый work item и возвращает его ID
"""
if not self.enabled: if not self.enabled:
return False return None
if minutes <= 0: if minutes <= 0:
return True return None
payload = {"duration": {"minutes": round(minutes)}} payload: dict = {
"duration": {"minutes": round(minutes)},
"date": int(datetime.now().timestamp() * 1000) # milliseconds
}
if description:
payload["text"] = description
try: try:
with self._get_client() as client: with self._get_client() as client:
url = f"{self.base_url}/api/issues/{issue_id}/timeTracking/workItems" url = f"{self.base_url}/api/issues/{issue_id}/timeTracking/workItems"
resp = client.post(url, headers=self.headers, json=payload) resp = client.post(url, headers=self.headers, json=payload)
if resp.status_code in (200, 201): if resp.status_code in (200, 201):
logger.info(f"YouTrack: +{round(minutes)} мин к {issue_id}") data = resp.json()
work_item_id = data.get('id')
logger.info(f"YouTrack: +{round(minutes)} мин к {issue_id} (ID: {work_item_id})")
return work_item_id
else:
logger.error(f"YouTrack: ошибка создания времени ({issue_id}): {resp.status_code} {resp.text}")
return None
except Exception as e:
logger.error(f"YouTrack: сетевая ошибка при создании времени ({issue_id}): {e}")
return None
def update_work_item(self, issue_id: str, work_item_id: str, minutes: float) -> bool:
"""
Обновляет существующий work item
"""
if not self.enabled:
return False
if minutes <= 0:
return False
payload = {
"duration": {"minutes": round(minutes)},
"id": work_item_id
}
try:
with self._get_client() as client:
url = f"{self.base_url}/api/issues/{issue_id}/timeTracking/workItems/{work_item_id}"
resp = client.post(url, headers=self.headers, json=payload) # YouTrack использует POST для обновления
if resp.status_code in (200, 201):
logger.info(f"YouTrack: обновлено время {work_item_id} на {round(minutes)} мин для {issue_id}")
return True return True
else: else:
logger.error(f"YouTrack: ошибка отправки времени ({issue_id}): {resp.status_code} {resp.text}") logger.error(
f"YouTrack: ошибка обновления времени ({work_item_id}): {resp.status_code} {resp.text}")
return False return False
except Exception as e: except Exception as e:
logger.error(f"YouTrack: сетевая ошибка при отправке времени ({issue_id}): {e}") logger.error(f"YouTrack: сетевая ошибка при обновлении времени ({work_item_id}): {e}")
return False return False
def get_issue_work_items(self, issue_id: str) -> List[dict]:
"""
Возвращает полные данные work items с метаданными для синхронизации
"""
if not self.enabled:
return []
try:
with self._get_client() as client:
# Запрашиваем больше полей для точного сопоставления
url = f"{self.base_url}/api/issues/{issue_id}/timeTracking/workItems"
url += "?fields=id,duration(minutes),created,text"
resp = client.get(url, headers=self.headers)
if resp.status_code != 200:
logger.error(f"YouTrack: ошибка получения work items ({issue_id}): {resp.status_code}")
return []
data = resp.json()
work_items = []
for item in data:
work_items.append({
'id': item.get('id'),
'minutes': item.get('duration', {}).get('minutes', 0),
'created': item.get('created'), # timestamp в миллисекундах
'text': item.get('text', '')
})
return work_items
except Exception as e:
logger.error(f"YouTrack: сетевая ошибка при получении work items для {issue_id}: {e}")
return []