diff --git a/src/citrus_time_tracker/app.py b/src/citrus_time_tracker/app.py index 91956ef..bff5077 100644 --- a/src/citrus_time_tracker/app.py +++ b/src/citrus_time_tracker/app.py @@ -38,6 +38,7 @@ class Application: self._tracking_thread: Optional[threading.Thread] = None self._running = True self.session_start_time: Optional[float] = None + self.idle_start_time: Optional[float] = None # Загружаем задачу if self.config.current_task_id: @@ -91,6 +92,7 @@ class Application: with self._lock: if not self.tracking_enabled or not self.current_task: self.session_start_time = None + self.idle_start_time = None return current_time = time.time() @@ -99,24 +101,106 @@ class Application: is_blocked = self._is_group_blocked(group_name) was_idle = self.is_idle + current_idle = (current_time - self.last_activity > self.config.idle_threshold_seconds) or is_blocked - if current_time - self.last_activity > 60 or is_blocked: + # === СЛУЧАЙ 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 - else: + self.maybe_save_task() + 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 + + # Решаем, создавать ли НОВУЮ сессию + create_new_session = 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} мин") + + # Причина 2: Смена календарного дня + if self.current_task.sessions: + last_session_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})") + + # Причина 3: Первая сессия для задачи + if not self.current_task.sessions: + create_new_session = True + reasons.append("первая сессия задачи") + + # Создаём новую сессию при необходимости + if create_new_session: + 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}: " + f"{', '.join(reasons)}" + ) + + # Начинаем отслеживание + self.session_start_time = current_time + self.last_window_group = group_name + self._add_work_detail_to_session(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 - 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(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] + + # Объединяем записи одной группы + 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 not was_idle and self.is_idle: - self.maybe_save_task() def _match_window_to_policy(self, window_info) -> str: title = window_info.title.lower() @@ -183,9 +267,17 @@ class Application: self.config.tracking_enabled = self.tracking_enabled self._save_config(self.config) - # Сохраняем при переходе в паузу if was_enabled and not self.tracking_enabled: + # Переход в паузу → сохраняем и сбрасываем состояние 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]): with self._lock: @@ -226,9 +318,30 @@ class Application: 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) + + if minutes == 0: + return + + # Создаём ОТДЕЛЬНУЮ сессию для ручного времени + 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: {minutes:+.1f} min" + ) + self.current_task.sessions.append(new_session) + logger.info(f"Добавлена ручная сессия: {minutes:+.1f} мин для {self.current_task.task_id}") + + # Сохраняем и синхронизируем + 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: with self._lock: diff --git a/src/citrus_time_tracker/models.py b/src/citrus_time_tracker/models.py index 5ab9786..016f74d 100644 --- a/src/citrus_time_tracker/models.py +++ b/src/citrus_time_tracker/models.py @@ -58,6 +58,8 @@ class TrackerConfig(BaseModel): tracking_enabled: bool = False current_task_id: Optional[str] = None window_policies: List[WindowPolicy] = Field(default_factory=list) + idle_threshold_seconds: int = 60 # Порог перехода в idle + new_session_after_idle_seconds: int = 600 # 10 минут для новой сессии @staticmethod def default_policies() -> List[WindowPolicy]: