From 534aa9a7d17f57b3bca0c6d4f9d9f86042506302 Mon Sep 17 00:00:00 2001 From: Mikan <72257910+Mikan-DS@users.noreply.github.com> Date: Thu, 12 Mar 2026 19:54:31 +0300 Subject: [PATCH] Changing logic --- src/citrus_time_tracker/app.py | 174 ++++++++++++++---------------- src/citrus_time_tracker/models.py | 16 ++- 2 files changed, 93 insertions(+), 97 deletions(-) diff --git a/src/citrus_time_tracker/app.py b/src/citrus_time_tracker/app.py index bff5077..40a91b3 100644 --- a/src/citrus_time_tracker/app.py +++ b/src/citrus_time_tracker/app.py @@ -99,49 +99,47 @@ class Application: window_info = self.platform.get_active_window() group_name = self._match_window_to_policy(window_info) 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 - current_idle = (current_time - self.last_activity > self.config.idle_threshold_seconds) or is_blocked + self.is_idle = current_idle # === СЛУЧАЙ 1: Переход в бездействие === if not was_idle and current_idle: - self.is_idle = True self.idle_start_time = current_time - self.last_window_group = None self.session_start_time = None + self.last_window_group = None self.maybe_save_task() + logger.debug(f"Переход в бездействие после {current_time - self.last_activity:.0f} сек") return # === СЛУЧАЙ 2: Выход из бездействия === if was_idle and not current_idle: - self.is_idle = False 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 - # Решаем, создавать ли НОВУЮ сессию - create_new_session = False + # Создаём НОВУЮ сессию при длительном простое или смене дня + should_create_new = False reasons = [] - # Причина 1: Длительное бездействие (>10 минут) if idle_duration > self.config.new_session_after_idle_seconds: - create_new_session = True - reasons.append(f"бездействие {idle_duration / 60:.1f} мин") + should_create_new = True + reasons.append(f"простой {idle_duration / 60:.1f} мин") - # Причина 2: Смена календарного дня if self.current_task.sessions: - last_session_date = self.current_task.sessions[-1].end_time.date() + last_date = self.current_task.sessions[-1].end_time.date() current_date = datetime.now().date() - if last_session_date < current_date: - create_new_session = True - reasons.append(f"смена дня ({last_session_date} → {current_date})") + if last_date < current_date: + should_create_new = True + reasons.append(f"новый день ({last_date} → {current_date})") - # Причина 3: Первая сессия для задачи if not self.current_task.sessions: - create_new_session = True - reasons.append("первая сессия задачи") + should_create_new = True + reasons.append("первая сессия") - # Создаём новую сессию при необходимости - if create_new_session: + if should_create_new: new_session = WorkSession( start_time=datetime.now(), end_time=datetime.now(), @@ -150,23 +148,20 @@ class Application: ) self.current_task.sessions.append(new_session) logger.info( - f"Новая сессия для {self.current_task.task_id}: " - f"{', '.join(reasons)}" + f"Новая сессия для {self.current_task.task_id}: {', '.join(reasons)}" ) - # Начинаем отслеживание - self.session_start_time = current_time - self.last_window_group = group_name - self._add_work_detail_to_session(group_name, 5.0) + # Добавляем первую запись в новую/текущую сессию + 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: - self.is_idle = False if self.last_window_group != group_name: self.last_window_group = group_name - # Гарантируем наличие сессии (на случай редких состояний) + # Гарантируем наличие сессии if not self.current_task.sessions: self.current_task.sessions.append(WorkSession( start_time=datetime.now(), @@ -175,22 +170,13 @@ class Application: synchronized=False )) - self._add_work_detail_to_session(group_name, 5.0) + self._add_work_detail_to_session(self.current_task.sessions[-1], group_name, 5.0) if self.session_start_time is None: self.session_start_time = current_time return - # === СЛУЧАЙ 4: Продолжение бездействия === - self.is_idle = True - - def _add_work_detail_to_session(self, group_name: str, seconds: float): - """Добавляет запись ТОЛЬКО в последнюю сессию (не создаёт новые)""" - if not self.current_task or not self.current_task.sessions: - logger.warning("Нет активной сессии для добавления времени") - return - - session = self.current_task.sessions[-1] - + 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: @@ -201,6 +187,15 @@ class Application: 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} мин)" + ) + def _match_window_to_policy(self, window_info) -> str: title = window_info.title.lower() @@ -317,12 +312,15 @@ class Application: def add_manual_time(self, minutes: float): with self._lock: if not self.current_task: + logger.warning("Невозможно добавить время: задача не выбрана") return if minutes == 0: + logger.info("Пропущено добавление 0 минут") return # Создаём ОТДЕЛЬНУЮ сессию для ручного времени + sign = "+" if minutes > 0 else "-" new_session = WorkSession( start_time=datetime.now(), end_time=datetime.now(), @@ -331,12 +329,15 @@ class Application: duration_seconds=abs(minutes) * 60 )], synchronized=False, - description=f"Manual: {minutes:+.1f} min" + 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}") + 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() @@ -411,14 +412,6 @@ class Application: self.keyboard_listener.stop() def sync_with_youtrack(self): - """ - Двусторонняя синхронизация с YouTrack: - 1. Проверяет существование задачи в YouTrack - 2. Загружает все work items из YouTrack - 3. Сопоставляет локальные сессии с удаленными - 4. Отправляет/обновляет несинхронизированные локальные сессии - 5. Добавляет новые сессии из YouTrack, которых нет локально - """ if not self.current_task: return @@ -427,12 +420,10 @@ class Application: logger.info("YouTrack: синхронизация отключена (нет настроек в .env)") return - # Проверяем существование задачи в YouTrack if not client.issue_exists(self.current_task.task_id): logger.warning(f"YouTrack: задача {self.current_task.task_id} не существует — синхронизация пропущена") return - # Загружаем все work items из YouTrack remote_items = client.get_issue_work_items(self.current_task.task_id) if not remote_items: logger.info(f"YouTrack: нет записей времени для задачи {self.current_task.task_id}") @@ -442,70 +433,60 @@ class Application: # Шаг 1: Обрабатываем локальные сессии for session in self.current_task.sessions: - total_minutes = sum(detail.duration_seconds for detail in session.details) / 60.0 + total_minutes = session.total_minutes - # Пропускаем нулевые или отрицательные сессии if total_minutes <= 0: session.synchronized = True + session.youtrack_duration_minutes = 0.0 changed = True continue - # Случай A: Сессия уже имеет ID — обновляем существующий work item + # === СЛУЧАЙ A: Сессия с ID — проверяем необходимость обновления === if session.id: - if not session.synchronized: + # Проверяем изменение ДАЖЕ если помечена как 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}") + logger.info( + f"Обновлена сессия {session.id} для {self.current_task.task_id}: " + f"{total_minutes:.1f} мин" + ) else: logger.warning(f"Не удалось обновить сессию {session.id}") - # Если synchronized=True — ничего не делаем + # else: сессия не изменилась — пропускаем - # Случай B: Сессия без ID, но помечена как синхронизированная - # (возможно, синхронизирована в предыдущих версиях без сохранения ID) - elif session.synchronized: - # Пропускаем — считаем, что время уже учтено в YouTrack - # Но для надежности можно попробовать найти совпадение по длительности - matching_remote = next( - (item for item in remote_items - if abs(item['minutes'] - total_minutes) < 1), # погрешность 1 минута - None - ) - if matching_remote: - session.id = matching_remote['id'] - # Удаляем из remote_items, чтобы не дублировать при добавлении - remote_items.remove(matching_remote) - logger.info(f"Сопоставлена сессия без ID с удаленной записью {session.id}") - changed = True - - # Случай C: Сессия без ID и не синхронизирована — создаем новый work item + # === СЛУЧАЙ B: Сессия без ID — создаём новую запись === else: work_item_id = client.add_work_item( self.current_task.task_id, total_minutes, - description=f"Auto-tracked: {', '.join(set(d.group_name for d in session.details))}" + 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_created_at = datetime.fromtimestamp( - next((item['created'] / 1000 for item in remote_items if item['id'] == work_item_id), - time.time()) - ) + 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"Создана запись {work_item_id} для {self.current_task.task_id}: " + f"{total_minutes:.1f} мин" + ) else: - logger.warning(f"Не удалось создать запись времени для сессии") + logger.warning(f"Не удалось создать запись для сессии без ID") - # Шаг 2: Добавляем удаленные work items, которых нет локально - # (например, добавленные вручную через веб-интерфейс YouTrack) - local_session_ids = {s.id for s in self.current_task.sessions if s.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_session_ids and remote_item['minutes'] > 0: - # Создаем новую сессию на основе удаленной записи + if remote_item['id'] not in local_ids and remote_item['minutes'] > 0: now = datetime.now() new_session = WorkSession( start_time=now, @@ -516,19 +497,24 @@ class Application: )], synchronized=True, id=remote_item['id'], - youtrack_created_at=datetime.fromtimestamp(remote_item['created'] / 1000) if remote_item.get( - 'created') else now, + 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']} ({remote_item['minutes']} мин) в локальную задачу") + 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}") + logger.info( + f"Синхронизация завершена для {self.current_task.task_id}: " + f"{len(self.current_task.sessions)} сессий" + ) def _save_task_no_sync(self, task: TrackedTask) -> None: """Сохраняет задачу БЕЗ повторной синхронизации (во избежание рекурсии)""" diff --git a/src/citrus_time_tracker/models.py b/src/citrus_time_tracker/models.py index 016f74d..d557192 100644 --- a/src/citrus_time_tracker/models.py +++ b/src/citrus_time_tracker/models.py @@ -14,15 +14,25 @@ class WorkSession(BaseModel): end_time: datetime details: List[WorkDetail] synchronized: bool = False - id: Optional[str] = None # ID work item в YouTrack - youtrack_created_at: Optional[datetime] = None # Время создания в YouTrack для сопоставления - description: Optional[str] = None # Описание для сопоставления (опционально) + 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): task_id: str