Changing logic
This commit is contained in:
@@ -99,49 +99,47 @@ 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)
|
||||||
|
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
|
||||||
current_idle = (current_time - self.last_activity > self.config.idle_threshold_seconds) or is_blocked
|
self.is_idle = current_idle
|
||||||
|
|
||||||
# === СЛУЧАЙ 1: Переход в бездействие ===
|
# === СЛУЧАЙ 1: Переход в бездействие ===
|
||||||
if not was_idle and current_idle:
|
if not was_idle and current_idle:
|
||||||
self.is_idle = True
|
|
||||||
self.idle_start_time = current_time
|
self.idle_start_time = current_time
|
||||||
self.last_window_group = None
|
|
||||||
self.session_start_time = None
|
self.session_start_time = None
|
||||||
|
self.last_window_group = None
|
||||||
self.maybe_save_task()
|
self.maybe_save_task()
|
||||||
|
logger.debug(f"Переход в бездействие после {current_time - self.last_activity:.0f} сек")
|
||||||
return
|
return
|
||||||
|
|
||||||
# === СЛУЧАЙ 2: Выход из бездействия ===
|
# === СЛУЧАЙ 2: Выход из бездействия ===
|
||||||
if was_idle and not current_idle:
|
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
|
idle_duration = current_time - self.idle_start_time if self.idle_start_time else 0
|
||||||
self.idle_start_time = None
|
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 = []
|
reasons = []
|
||||||
|
|
||||||
# Причина 1: Длительное бездействие (>10 минут)
|
|
||||||
if idle_duration > self.config.new_session_after_idle_seconds:
|
if idle_duration > self.config.new_session_after_idle_seconds:
|
||||||
create_new_session = True
|
should_create_new = True
|
||||||
reasons.append(f"бездействие {idle_duration / 60:.1f} мин")
|
reasons.append(f"простой {idle_duration / 60:.1f} мин")
|
||||||
|
|
||||||
# Причина 2: Смена календарного дня
|
|
||||||
if self.current_task.sessions:
|
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()
|
current_date = datetime.now().date()
|
||||||
if last_session_date < current_date:
|
if last_date < current_date:
|
||||||
create_new_session = True
|
should_create_new = True
|
||||||
reasons.append(f"смена дня ({last_session_date} → {current_date})")
|
reasons.append(f"новый день ({last_date} → {current_date})")
|
||||||
|
|
||||||
# Причина 3: Первая сессия для задачи
|
|
||||||
if not self.current_task.sessions:
|
if not self.current_task.sessions:
|
||||||
create_new_session = True
|
should_create_new = True
|
||||||
reasons.append("первая сессия задачи")
|
reasons.append("первая сессия")
|
||||||
|
|
||||||
# Создаём новую сессию при необходимости
|
if should_create_new:
|
||||||
if create_new_session:
|
|
||||||
new_session = WorkSession(
|
new_session = WorkSession(
|
||||||
start_time=datetime.now(),
|
start_time=datetime.now(),
|
||||||
end_time=datetime.now(),
|
end_time=datetime.now(),
|
||||||
@@ -150,23 +148,20 @@ class Application:
|
|||||||
)
|
)
|
||||||
self.current_task.sessions.append(new_session)
|
self.current_task.sessions.append(new_session)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Новая сессия для {self.current_task.task_id}: "
|
f"Новая сессия для {self.current_task.task_id}: {', '.join(reasons)}"
|
||||||
f"{', '.join(reasons)}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Начинаем отслеживание
|
# Добавляем первую запись в новую/текущую сессию
|
||||||
self.session_start_time = current_time
|
if self.current_task.sessions:
|
||||||
self.last_window_group = group_name
|
self._add_work_detail_to_session(self.current_task.sessions[-1], group_name, 5.0)
|
||||||
self._add_work_detail_to_session(group_name, 5.0)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# === СЛУЧАЙ 3: Продолжение активной работы ===
|
# === СЛУЧАЙ 3: Продолжение активной работы ===
|
||||||
if not current_idle:
|
if not current_idle:
|
||||||
self.is_idle = False
|
|
||||||
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
|
||||||
|
|
||||||
# Гарантируем наличие сессии (на случай редких состояний)
|
# Гарантируем наличие сессии
|
||||||
if not self.current_task.sessions:
|
if not self.current_task.sessions:
|
||||||
self.current_task.sessions.append(WorkSession(
|
self.current_task.sessions.append(WorkSession(
|
||||||
start_time=datetime.now(),
|
start_time=datetime.now(),
|
||||||
@@ -175,22 +170,13 @@ class Application:
|
|||||||
synchronized=False
|
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:
|
if self.session_start_time is None:
|
||||||
self.session_start_time = current_time
|
self.session_start_time = current_time
|
||||||
return
|
return
|
||||||
|
|
||||||
# === СЛУЧАЙ 4: Продолжение бездействия ===
|
def _add_work_detail_to_session(self, session: WorkSession, group_name: str, seconds: float):
|
||||||
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:
|
for detail in session.details:
|
||||||
if detail.group_name == group_name:
|
if detail.group_name == group_name:
|
||||||
@@ -201,6 +187,15 @@ class Application:
|
|||||||
|
|
||||||
session.end_time = datetime.now()
|
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:
|
def _match_window_to_policy(self, window_info) -> str:
|
||||||
title = window_info.title.lower()
|
title = window_info.title.lower()
|
||||||
@@ -317,12 +312,15 @@ class Application:
|
|||||||
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
|
||||||
|
|
||||||
if minutes == 0:
|
if minutes == 0:
|
||||||
|
logger.info("Пропущено добавление 0 минут")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Создаём ОТДЕЛЬНУЮ сессию для ручного времени
|
# Создаём ОТДЕЛЬНУЮ сессию для ручного времени
|
||||||
|
sign = "+" if minutes > 0 else "-"
|
||||||
new_session = WorkSession(
|
new_session = WorkSession(
|
||||||
start_time=datetime.now(),
|
start_time=datetime.now(),
|
||||||
end_time=datetime.now(),
|
end_time=datetime.now(),
|
||||||
@@ -331,12 +329,15 @@ class Application:
|
|||||||
duration_seconds=abs(minutes) * 60
|
duration_seconds=abs(minutes) * 60
|
||||||
)],
|
)],
|
||||||
synchronized=False,
|
synchronized=False,
|
||||||
description=f"Manual: {minutes:+.1f} min"
|
description=f"Manual: {sign}{abs(minutes):.1f} min"
|
||||||
)
|
)
|
||||||
self.current_task.sessions.append(new_session)
|
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)
|
self.save_task(self.current_task)
|
||||||
try:
|
try:
|
||||||
self.sync_with_youtrack()
|
self.sync_with_youtrack()
|
||||||
@@ -411,14 +412,6 @@ class Application:
|
|||||||
self.keyboard_listener.stop()
|
self.keyboard_listener.stop()
|
||||||
|
|
||||||
def sync_with_youtrack(self):
|
def sync_with_youtrack(self):
|
||||||
"""
|
|
||||||
Двусторонняя синхронизация с YouTrack:
|
|
||||||
1. Проверяет существование задачи в YouTrack
|
|
||||||
2. Загружает все work items из YouTrack
|
|
||||||
3. Сопоставляет локальные сессии с удаленными
|
|
||||||
4. Отправляет/обновляет несинхронизированные локальные сессии
|
|
||||||
5. Добавляет новые сессии из YouTrack, которых нет локально
|
|
||||||
"""
|
|
||||||
if not self.current_task:
|
if not self.current_task:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -427,12 +420,10 @@ class Application:
|
|||||||
logger.info("YouTrack: синхронизация отключена (нет настроек в .env)")
|
logger.info("YouTrack: синхронизация отключена (нет настроек в .env)")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Проверяем существование задачи в YouTrack
|
|
||||||
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} не существует — синхронизация пропущена")
|
logger.warning(f"YouTrack: задача {self.current_task.task_id} не существует — синхронизация пропущена")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Загружаем все work items из YouTrack
|
|
||||||
remote_items = client.get_issue_work_items(self.current_task.task_id)
|
remote_items = client.get_issue_work_items(self.current_task.task_id)
|
||||||
if not remote_items:
|
if not remote_items:
|
||||||
logger.info(f"YouTrack: нет записей времени для задачи {self.current_task.task_id}")
|
logger.info(f"YouTrack: нет записей времени для задачи {self.current_task.task_id}")
|
||||||
@@ -442,70 +433,60 @@ class Application:
|
|||||||
|
|
||||||
# Шаг 1: Обрабатываем локальные сессии
|
# Шаг 1: Обрабатываем локальные сессии
|
||||||
for session in self.current_task.sessions:
|
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:
|
if total_minutes <= 0:
|
||||||
session.synchronized = True
|
session.synchronized = True
|
||||||
|
session.youtrack_duration_minutes = 0.0
|
||||||
changed = True
|
changed = True
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Случай A: Сессия уже имеет ID — обновляем существующий work item
|
# === СЛУЧАЙ A: Сессия с ID — проверяем необходимость обновления ===
|
||||||
if session.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):
|
if client.update_work_item(self.current_task.task_id, session.id, total_minutes):
|
||||||
session.synchronized = True
|
session.synchronized = True
|
||||||
|
session.youtrack_duration_minutes = total_minutes
|
||||||
changed = True
|
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:
|
else:
|
||||||
logger.warning(f"Не удалось обновить сессию {session.id}")
|
logger.warning(f"Не удалось обновить сессию {session.id}")
|
||||||
# Если synchronized=True — ничего не делаем
|
# else: сессия не изменилась — пропускаем
|
||||||
|
|
||||||
# Случай B: Сессия без ID, но помечена как синхронизированная
|
# === СЛУЧАЙ 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
|
|
||||||
else:
|
else:
|
||||||
work_item_id = client.add_work_item(
|
work_item_id = client.add_work_item(
|
||||||
self.current_task.task_id,
|
self.current_task.task_id,
|
||||||
total_minutes,
|
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:
|
if work_item_id:
|
||||||
session.id = work_item_id
|
session.id = work_item_id
|
||||||
session.synchronized = True
|
session.synchronized = True
|
||||||
# Сохраняем время создания для будущего сопоставления
|
session.youtrack_duration_minutes = total_minutes
|
||||||
session.youtrack_created_at = datetime.fromtimestamp(
|
# Пытаемся найти точное совпадение для youtrack_created_at
|
||||||
next((item['created'] / 1000 for item in remote_items if item['id'] == work_item_id),
|
remote_match = next((r for r in remote_items if r['id'] == work_item_id), None)
|
||||||
time.time())
|
if remote_match and remote_match.get('created'):
|
||||||
)
|
session.youtrack_created_at = datetime.fromtimestamp(remote_match['created'] / 1000)
|
||||||
changed = True
|
changed = True
|
||||||
logger.info(
|
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:
|
else:
|
||||||
logger.warning(f"Не удалось создать запись времени для сессии")
|
logger.warning(f"Не удалось создать запись для сессии без ID")
|
||||||
|
|
||||||
# Шаг 2: Добавляем удаленные work items, которых нет локально
|
# Шаг 2: Добавляем удалённые записи, которых нет локально
|
||||||
# (например, добавленные вручную через веб-интерфейс YouTrack)
|
local_ids = {s.id for s in self.current_task.sessions if s.id}
|
||||||
local_session_ids = {s.id for s in self.current_task.sessions if s.id}
|
|
||||||
|
|
||||||
for remote_item in remote_items:
|
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()
|
now = datetime.now()
|
||||||
new_session = WorkSession(
|
new_session = WorkSession(
|
||||||
start_time=now,
|
start_time=now,
|
||||||
@@ -516,19 +497,24 @@ class Application:
|
|||||||
)],
|
)],
|
||||||
synchronized=True,
|
synchronized=True,
|
||||||
id=remote_item['id'],
|
id=remote_item['id'],
|
||||||
youtrack_created_at=datetime.fromtimestamp(remote_item['created'] / 1000) if remote_item.get(
|
youtrack_duration_minutes=remote_item['minutes'],
|
||||||
'created') else now,
|
youtrack_created_at=datetime.fromtimestamp(remote_item['created'] / 1000)
|
||||||
|
if remote_item.get('created') else now,
|
||||||
description=remote_item.get('text')
|
description=remote_item.get('text')
|
||||||
)
|
)
|
||||||
self.current_task.sessions.append(new_session)
|
self.current_task.sessions.append(new_session)
|
||||||
changed = True
|
changed = True
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Добавлена удаленная запись времени {remote_item['id']} ({remote_item['minutes']} мин) в локальную задачу")
|
f"Добавлена удалённая запись {remote_item['id']} "
|
||||||
|
f"({remote_item['minutes']} мин) в локальную задачу"
|
||||||
|
)
|
||||||
|
|
||||||
# Сохраняем задачу, если были изменения
|
|
||||||
if changed:
|
if changed:
|
||||||
self._save_task_no_sync(self.current_task)
|
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:
|
def _save_task_no_sync(self, task: TrackedTask) -> None:
|
||||||
"""Сохраняет задачу БЕЗ повторной синхронизации (во избежание рекурсии)"""
|
"""Сохраняет задачу БЕЗ повторной синхронизации (во избежание рекурсии)"""
|
||||||
|
|||||||
@@ -14,15 +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 # ID work item в YouTrack
|
id: Optional[str] = None
|
||||||
youtrack_created_at: Optional[datetime] = None # Время создания в YouTrack для сопоставления
|
youtrack_created_at: Optional[datetime] = None
|
||||||
description: Optional[str] = None # Описание для сопоставления (опционально)
|
description: Optional[str] = None
|
||||||
|
youtrack_duration_minutes: Optional[float] = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
json_encoders = {
|
json_encoders = {
|
||||||
datetime: lambda v: v.isoformat()
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user