From c8599b3d13ed13489ed596ec4ad9bd8b3ad06ad2 Mon Sep 17 00:00:00 2001 From: grigo Date: Tue, 9 Jun 2026 11:54:32 +0300 Subject: [PATCH] added more pomidoro --- backend/app/api/routes/pomodoro.py | 40 +++++ backend/app/character/card.py | 7 +- backend/app/chat/notices.py | 84 +++++++-- backend/app/db/base.py | 2 + backend/app/db/migrate.py | 32 ++++ backend/app/db/models.py | 19 +++ backend/app/main.py | 8 +- backend/app/pomodoro/completion.py | 101 +++++++++++ backend/app/pomodoro/cycle.py | 89 ++++++++++ backend/app/pomodoro/service.py | 189 ++++++++++++++++++--- backend/app/pomodoro/watcher.py | 38 +++++ backend/app/tools/registry.py | 101 ++++++++--- frontend/src/api/client.ts | 34 ++++ frontend/src/components/PomodoroWidget.css | 9 +- frontend/src/components/PomodoroWidget.tsx | 18 +- frontend/src/pages/Chat.tsx | 11 +- frontend/src/pages/Pomodoro.css | 28 ++- frontend/src/pages/Pomodoro.tsx | 87 +++++++--- frontend/src/utils/pomodoro.ts | 9 + frontend/tsconfig.tsbuildinfo | 2 +- 20 files changed, 817 insertions(+), 91 deletions(-) create mode 100644 backend/app/db/migrate.py create mode 100644 backend/app/pomodoro/completion.py create mode 100644 backend/app/pomodoro/cycle.py create mode 100644 backend/app/pomodoro/watcher.py create mode 100644 frontend/src/utils/pomodoro.ts diff --git a/backend/app/api/routes/pomodoro.py b/backend/app/api/routes/pomodoro.py index c41372b..d3e2e22 100644 --- a/backend/app/api/routes/pomodoro.py +++ b/backend/app/api/routes/pomodoro.py @@ -58,3 +58,43 @@ def stop_pomodoro(payload: PomodoroStop, db: Session = Depends(get_db)) -> dict: @router.get("/history") def get_history(limit: int = 20, db: Session = Depends(get_db)) -> list[dict]: return PomodoroService(db).history(limit=limit) + + +@router.post("/work/start") +def start_work(payload: PomodoroStart, db: Session = Depends(get_db)) -> dict: + try: + return PomodoroService(db).start_work( + duration_min=payload.duration_min, + task_note=payload.task_note, + ) + except ValueError as exc: + raise _handle_value_error(exc) from exc + + +@router.post("/break/short/start") +def start_short_break(duration_min: int | None = None, db: Session = Depends(get_db)) -> dict: + try: + return PomodoroService(db).start_short_break(duration_min=duration_min) + except ValueError as exc: + raise _handle_value_error(exc) from exc + + +@router.post("/break/long/start") +def start_long_break(duration_min: int | None = None, db: Session = Depends(get_db)) -> dict: + try: + return PomodoroService(db).start_long_break(duration_min=duration_min) + except ValueError as exc: + raise _handle_value_error(exc) from exc + + +@router.post("/cycle/reset") +def reset_cycle(clear_task: bool = False, db: Session = Depends(get_db)) -> dict: + return PomodoroService(db).reset_cycle(clear_task=clear_task) + + +@router.post("/skip") +def skip_phase(db: Session = Depends(get_db)) -> dict: + try: + return PomodoroService(db).skip_phase() + except ValueError as exc: + raise _handle_value_error(exc) from exc diff --git a/backend/app/character/card.py b/backend/app/character/card.py index dbddb85..ba47142 100644 --- a/backend/app/character/card.py +++ b/backend/app/character/card.py @@ -1,11 +1,14 @@ from typing import Any TOOLS_INSTRUCTIONS = """ -Ты также домашний ассистент с инструментами. Обязательные правила: +Ты также домашний ассистент с инструментами помидоро-цикла (работа → перерыв → работа → длинный перерыв → сброс). +Обязательные правила: - Любой вопрос о таймере, помидоро, задачах или истории — СНАЧАЛА вызывай соответствующий инструмент. - Никогда не выдумывай статус таймера или список задач. - После вызова инструмента кратко объясни результат пользователю по-человечески. -- Инструменты: get_pomodoro_status, start_pomodoro, stop_pomodoro, get_pomodoro_history. +- Инструменты: get_pomodoro_status, start_pomodoro, start_short_break, start_long_break, + stop_pomodoro, skip_pomodoro_phase, reset_pomodoro_cycle, get_pomodoro_history. +- reset_pomodoro_cycle — только когда пользователь явно просит сбросить цикл. """.strip() DEFAULT_CARD: dict[str, Any] = { diff --git a/backend/app/chat/notices.py b/backend/app/chat/notices.py index 93ab5c9..197e1cd 100644 --- a/backend/app/chat/notices.py +++ b/backend/app/chat/notices.py @@ -1,12 +1,41 @@ import json from typing import Any +from app.db.models import PomodoroSession +from app.pomodoro.cycle import PHASE_LONG_BREAK, PHASE_SHORT_BREAK, PHASE_WORK + +PHASE_LABELS = { + PHASE_WORK: "Работа", + PHASE_SHORT_BREAK: "Короткий перерыв", + PHASE_LONG_BREAK: "Длинный перерыв", +} + def _format_time(seconds: int) -> str: minutes, secs = divmod(max(0, seconds), 60) return f"{minutes:02d}:{secs:02d}" +def format_phase_completed_notice( + session: PomodoroSession, + next_phase: str | None, +) -> str: + phase_label = PHASE_LABELS.get(session.phase, session.phase) + task = session.task_note or "без описания" + lines = [f"⏱ **{phase_label} завершена** · {session.duration_min} мин · _{task}_"] + + if next_phase == PHASE_SHORT_BREAK: + lines.append("Дальше: короткий перерыв ☕") + elif next_phase == PHASE_LONG_BREAK: + lines.append("Дальше: длинный перерыв 🌴 · цикл почти завершён") + elif next_phase == PHASE_WORK: + lines.append("Дальше: снова работа 💪") + else: + lines.append("Цикл сброшен. Можно отдохнуть и начать заново.") + + return "\n".join(lines) + + def format_pomodoro_notice(tool_name: str, raw_result: str) -> str | None: try: data = json.loads(raw_result) @@ -16,7 +45,23 @@ def format_pomodoro_notice(tool_name: str, raw_result: str) -> str | None: if isinstance(data, dict) and "error" in data: return f"⏱ Помидоро: {data['error']}" - if tool_name in ("get_pomodoro_status", "start_pomodoro", "stop_pomodoro"): + if tool_name == "reset_pomodoro_cycle": + cycle = data.get("cycle", data) + return ( + "⏱ **Цикл помидоро сброшен** · " + f"прогресс: {cycle.get('completed_work_sessions', 0)}/" + f"{cycle.get('sessions_until_long_break', 4)}" + ) + + if tool_name in ( + "get_pomodoro_status", + "start_pomodoro", + "start_work", + "start_short_break", + "start_long_break", + "stop_pomodoro", + "skip_pomodoro_phase", + ): return _format_status_notice(data) if tool_name == "get_pomodoro_history": @@ -27,31 +72,40 @@ def format_pomodoro_notice(tool_name: str, raw_result: str) -> str | None: def _format_status_notice(data: dict[str, Any]) -> str: status = data.get("status", "idle") + phase = data.get("phase", PHASE_WORK) + phase_label = PHASE_LABELS.get(phase, phase) task = data.get("task_note") or "без описания" remaining = data.get("remaining_seconds", 0) duration = data.get("duration_min", 25) + cycle = data.get("cycle", {}) + cycle_info = "" + if cycle: + cycle_info = ( + f" · цикл {cycle.get('completed_work_sessions', 0)}/" + f"{cycle.get('sessions_until_long_break', 4)}" + ) if status == "idle": - return "⏱ **Помидоро:** таймер не запущен." + return f"⏱ **Помидоро:** таймер не запущен{cycle_info}." if status == "running": return ( - f"⏱ **Помидоро запущен** · осталось **{_format_time(remaining)}** " - f"из {duration} мин · задача: _{task}_" + f"⏱ **{phase_label}** · осталось **{_format_time(remaining)}** " + f"из {duration} мин · _{task}_{cycle_info}" ) if status == "paused": elapsed = data.get("elapsed_seconds", 0) return ( - f"⏱ **Помидоро на паузе** · прошло {_format_time(elapsed)} " - f"из {duration} мин · задача: _{task}_" + f"⏱ **{phase_label} на паузе** · прошло {_format_time(elapsed)} " + f"из {duration} мин · _{task}_{cycle_info}" ) if status == "completed": - return f"⏱ **Помидоро завершён** · {duration} мин · задача: _{task}_" + return f"⏱ **{phase_label} завершена** · {duration} мин · _{task}_" if status == "cancelled": - return f"⏱ **Помидоро отменён** · задача: _{task}_" + return f"⏱ **{phase_label} отменена** · _{task}_" return f"⏱ Помидоро: {status}" @@ -63,13 +117,21 @@ def _format_history_notice(data: Any) -> str: lines = ["⏱ **История помидоро:**"] for item in data[:10]: task = item.get("task_note") or "без описания" - status = item.get("status", "?") + phase = PHASE_LABELS.get(item.get("phase", ""), item.get("phase", "?")) duration = item.get("duration_min", "?") - lines.append(f"- {task} ({duration} мин, {status})") + lines.append(f"- {phase}: {task} ({duration} мин)") return "\n".join(lines) def format_pomodoro_context(status: dict[str, Any]) -> str: notice = _format_status_notice(status) - return f"[Актуальный статус помидоро]\n{notice}" + cycle = status.get("cycle", {}) + extra = "" + if cycle: + extra = ( + f"\nНастройки цикла: работа {cycle.get('work_duration_min')} мин, " + f"перерыв {cycle.get('short_break_min')} мин, " + f"длинный {cycle.get('long_break_min')} мин." + ) + return f"[Актуальный статус помидоро]\n{notice}{extra}" diff --git a/backend/app/db/base.py b/backend/app/db/base.py index 0340449..5ed40fe 100644 --- a/backend/app/db/base.py +++ b/backend/app/db/base.py @@ -27,8 +27,10 @@ SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) def init_db() -> None: from app.db import models # noqa: F401 + from app.db.migrate import run_migrations Base.metadata.create_all(bind=engine) + run_migrations() def get_db() -> Generator[Session, None, None]: diff --git a/backend/app/db/migrate.py b/backend/app/db/migrate.py new file mode 100644 index 0000000..9cf973f --- /dev/null +++ b/backend/app/db/migrate.py @@ -0,0 +1,32 @@ +from sqlalchemy import inspect, text + +from app.db.base import engine + + +def run_migrations() -> None: + inspector = inspect(engine) + + if "pomodoro_sessions" in inspector.get_table_names(): + columns = {col["name"] for col in inspector.get_columns("pomodoro_sessions")} + with engine.begin() as conn: + if "phase" not in columns: + conn.execute( + text("ALTER TABLE pomodoro_sessions ADD COLUMN phase VARCHAR(32) DEFAULT 'work'") + ) + if "completion_notified" not in columns: + conn.execute( + text( + "ALTER TABLE pomodoro_sessions " + "ADD COLUMN completion_notified BOOLEAN DEFAULT 0" + ) + ) + + if "pomodoro_cycles" not in inspector.get_table_names(): + return + + columns = {col["name"] for col in inspector.get_columns("pomodoro_cycles")} + with engine.begin() as conn: + if "chat_notify_seq" not in columns: + conn.execute( + text("ALTER TABLE pomodoro_cycles ADD COLUMN chat_notify_seq INTEGER DEFAULT 0") + ) diff --git a/backend/app/db/models.py b/backend/app/db/models.py index d33d6a8..bb67960 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -35,15 +35,34 @@ class Message(Base): session: Mapped["ChatSession"] = relationship(back_populates="messages") +class PomodoroCycle(Base): + __tablename__ = "pomodoro_cycles" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + work_duration_min: Mapped[int] = mapped_column(Integer, default=25) + short_break_min: Mapped[int] = mapped_column(Integer, default=5) + long_break_min: Mapped[int] = mapped_column(Integer, default=15) + sessions_until_long_break: Mapped[int] = mapped_column(Integer, default=4) + completed_work_sessions: Mapped[int] = mapped_column(Integer, default=0) + task_note: Mapped[str] = mapped_column(Text, default="") + auto_advance: Mapped[bool] = mapped_column(Boolean, default=True) + chat_notify_seq: Mapped[int] = mapped_column(Integer, default=0) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + class PomodoroSession(Base): __tablename__ = "pomodoro_sessions" id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) status: Mapped[str] = mapped_column(String(32), default="idle") + phase: Mapped[str] = mapped_column(String(32), default="work") duration_min: Mapped[int] = mapped_column(Integer, default=25) task_note: Mapped[str] = mapped_column(Text, default="") result: Mapped[str | None] = mapped_column(Text, nullable=True) completed: Mapped[bool] = mapped_column(Boolean, default=False) + completion_notified: Mapped[bool] = mapped_column(Boolean, default=False) started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) paused_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) elapsed_seconds: Mapped[int] = mapped_column(Integer, default=0) diff --git a/backend/app/main.py b/backend/app/main.py index 6ca6284..21ac9ba 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,4 +1,5 @@ -from contextlib import asynccontextmanager +import asyncio +from contextlib import asynccontextmanager, suppress from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware @@ -6,12 +7,17 @@ from fastapi.middleware.cors import CORSMiddleware from app.api.routes import api_router from app.config import get_settings from app.db.base import init_db +from app.pomodoro.watcher import pomodoro_watcher_loop @asynccontextmanager async def lifespan(_: FastAPI): init_db() + watcher_task = asyncio.create_task(pomodoro_watcher_loop()) yield + watcher_task.cancel() + with suppress(asyncio.CancelledError): + await watcher_task def create_app() -> FastAPI: diff --git a/backend/app/pomodoro/completion.py b/backend/app/pomodoro/completion.py new file mode 100644 index 0000000..a5a0fe9 --- /dev/null +++ b/backend/app/pomodoro/completion.py @@ -0,0 +1,101 @@ +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.character.service import CharacterService +from app.chat.notices import format_phase_completed_notice +from app.db.models import ChatSession, Message, PomodoroSession +from app.llm.client import LLMClient +from app.pomodoro.cycle import PHASE_LONG_BREAK, PHASE_SHORT_BREAK, PHASE_WORK, CycleManager +from app.pomodoro.service import PomodoroService + +PHASE_LABELS = { + PHASE_WORK: "работа", + PHASE_SHORT_BREAK: "короткий перерыв", + PHASE_LONG_BREAK: "длинный перерыв", +} + + +class PomodoroCompletionHandler: + def __init__(self, db: Session): + self.db = db + self.pomodoro = PomodoroService(db) + self.cycle = CycleManager(db) + self.llm = LLMClient() + self.character = CharacterService() + + def _latest_chat_session_id(self) -> int | None: + stmt = select(ChatSession).order_by(ChatSession.updated_at.desc()).limit(1) + session = self.db.scalar(stmt) + return session.id if session else None + + def _save_chat_message(self, session_id: int, role: str, content: str) -> None: + self.db.add(Message(session_id=session_id, role=role, content=content)) + chat = self.db.get(ChatSession, session_id) + if chat: + chat.updated_at = chat.updated_at # trigger onupdate + self.db.commit() + + async def _generate_llm_comment( + self, + session: PomodoroSession, + next_phase: str | None, + ) -> str: + cycle = self.cycle.to_dict() + phase_label = PHASE_LABELS.get(session.phase, session.phase) + next_label = PHASE_LABELS.get(next_phase, "пауза") if next_phase else "отдых, цикл сброшен" + work_done = cycle["completed_work_sessions"] + if session.phase == PHASE_WORK: + work_done += 1 + + system = self.character.get_system_prompt() + user_prompt = f"""Фаза помидоро «{phase_label}» только что завершилась. +Задача: {session.task_note or 'без описания'} +Прогресс цикла: {work_done}/{cycle['sessions_until_long_break']} работ. +Следующая фаза: {next_label}. + +Напиши пользователю короткое сообщение (2-4 предложения) на русском: поздравь, поддержи или предложи отдохнуть. Без markdown.""" + + result = await self.llm.complete( + [ + {"role": "system", "content": system}, + {"role": "user", "content": user_prompt}, + ] + ) + return (result.get("content") or "").strip() or "Фаза завершена! Отличная работа." + + def _resolve_next_phase(self, session: PomodoroSession) -> str | None: + phase = session.phase + cycle = self.cycle.get() + if phase == PHASE_WORK: + if cycle.completed_work_sessions + 1 >= cycle.sessions_until_long_break: + return PHASE_LONG_BREAK + return PHASE_SHORT_BREAK + if phase == PHASE_SHORT_BREAK: + return PHASE_WORK + if phase == PHASE_LONG_BREAK: + return None + return None + + async def process(self, session: PomodoroSession) -> None: + if session.completion_notified: + return + + next_phase = self._resolve_next_phase(session) + notice = format_phase_completed_notice(session, next_phase) + + chat_id = self._latest_chat_session_id() + if not chat_id: + chat = ChatSession(title="Помидоро") + self.db.add(chat) + self.db.commit() + self.db.refresh(chat) + chat_id = chat.id + + self._save_chat_message(chat_id, "notice", notice) + + comment = await self._generate_llm_comment(session, next_phase) + self._save_chat_message(chat_id, "assistant", comment) + + self.cycle.bump_notify_seq() + self.pomodoro.mark_notified(session) + self.pomodoro.advance_after_completion(session) diff --git a/backend/app/pomodoro/cycle.py b/backend/app/pomodoro/cycle.py new file mode 100644 index 0000000..a0be349 --- /dev/null +++ b/backend/app/pomodoro/cycle.py @@ -0,0 +1,89 @@ +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.db.models import PomodoroCycle + +PHASE_WORK = "work" +PHASE_SHORT_BREAK = "short_break" +PHASE_LONG_BREAK = "long_break" + + +class CycleManager: + def __init__(self, db: Session): + self.db = db + + def get(self) -> PomodoroCycle: + cycle = self.db.scalar(select(PomodoroCycle).limit(1)) + if not cycle: + cycle = PomodoroCycle() + self.db.add(cycle) + self.db.commit() + self.db.refresh(cycle) + return cycle + + def to_dict(self, cycle: PomodoroCycle | None = None) -> dict: + c = cycle or self.get() + return { + "completed_work_sessions": c.completed_work_sessions, + "sessions_until_long_break": c.sessions_until_long_break, + "task_note": c.task_note, + "work_duration_min": c.work_duration_min, + "short_break_min": c.short_break_min, + "long_break_min": c.long_break_min, + "auto_advance": c.auto_advance, + "chat_notify_seq": c.chat_notify_seq, + } + + def reset(self, clear_task: bool = False) -> dict: + cycle = self.get() + cycle.completed_work_sessions = 0 + if clear_task: + cycle.task_note = "" + self.db.commit() + self.db.refresh(cycle) + return self.to_dict(cycle) + + def bump_notify_seq(self) -> int: + cycle = self.get() + cycle.chat_notify_seq += 1 + self.db.commit() + self.db.refresh(cycle) + return cycle.chat_notify_seq + + def on_work_completed(self) -> str: + """Returns next phase: short_break or long_break.""" + cycle = self.get() + cycle.completed_work_sessions += 1 + if cycle.completed_work_sessions >= cycle.sessions_until_long_break: + next_phase = PHASE_LONG_BREAK + else: + next_phase = PHASE_SHORT_BREAK + self.db.commit() + return next_phase + + def on_long_break_completed(self) -> None: + cycle = self.get() + cycle.completed_work_sessions = 0 + self.db.commit() + + def duration_for_phase(self, phase: str, cycle: PomodoroCycle | None = None) -> int: + c = cycle or self.get() + if phase == PHASE_WORK: + return c.work_duration_min + if phase == PHASE_SHORT_BREAK: + return c.short_break_min + if phase == PHASE_LONG_BREAK: + return c.long_break_min + return c.work_duration_min + + def next_phase_after(self, completed_phase: str) -> str | None: + if completed_phase == PHASE_WORK: + cycle = self.get() + if cycle.completed_work_sessions >= cycle.sessions_until_long_break: + return PHASE_LONG_BREAK + return PHASE_SHORT_BREAK + if completed_phase == PHASE_SHORT_BREAK: + return PHASE_WORK + if completed_phase == PHASE_LONG_BREAK: + return None + return None diff --git a/backend/app/pomodoro/service.py b/backend/app/pomodoro/service.py index 3184656..3957795 100644 --- a/backend/app/pomodoro/service.py +++ b/backend/app/pomodoro/service.py @@ -4,6 +4,12 @@ from sqlalchemy import select from sqlalchemy.orm import Session from app.db.models import PomodoroSession +from app.pomodoro.cycle import ( + PHASE_LONG_BREAK, + PHASE_SHORT_BREAK, + PHASE_WORK, + CycleManager, +) def _utcnow() -> datetime: @@ -13,6 +19,7 @@ def _utcnow() -> datetime: class PomodoroService: def __init__(self, db: Session): self.db = db + self.cycle = CycleManager(db) def _get_active(self) -> PomodoroSession | None: stmt = ( @@ -37,30 +44,95 @@ class PomodoroService: total = session.duration_min * 60 return max(0, total - self._elapsed(session)) + def _try_auto_complete(self, session: PomodoroSession) -> bool: + if session.status != "running": + return False + if self._remaining(session) > 0: + return False + self._finalize_session(session, auto=True) + return True + + def _finalize_session( + self, + session: PomodoroSession, + *, + auto: bool, + result: str = "", + completed: bool | None = None, + cancelled: bool = False, + ) -> None: + session.elapsed_seconds = self._elapsed(session) + session.started_at = None + session.finished_at = _utcnow() + session.completion_notified = False + session.result = result or None + + if cancelled: + session.status = "cancelled" + session.completed = False + elif completed is not None: + session.status = "completed" + session.completed = completed + else: + session.status = "completed" + session.completed = True + + self.db.commit() + self.db.refresh(session) + + def _start_phase( + self, + phase: str, + *, + duration_min: int | None = None, + task_note: str | None = None, + ) -> PomodoroSession: + active = self._get_active() + if active: + raise ValueError("Таймер уже запущен. Сначала остановите текущую сессию.") + + cycle = self.cycle.get() + if task_note is not None: + cycle.task_note = task_note + elif phase == PHASE_WORK and not cycle.task_note: + cycle.task_note = "" + + duration = duration_min or self.cycle.duration_for_phase(phase, cycle) + note = task_note if task_note is not None else cycle.task_note + + session = PomodoroSession( + status="running", + phase=phase, + duration_min=duration, + task_note=note, + started_at=_utcnow(), + ) + self.db.add(session) + self.db.commit() + self.db.refresh(session) + return session + def _to_status_dict(self, session: PomodoroSession | None) -> dict: + cycle_dict = self.cycle.to_dict() if not session: return { "status": "idle", - "duration_min": 25, - "task_note": "", + "phase": PHASE_WORK, + "duration_min": cycle_dict["work_duration_min"], + "task_note": cycle_dict["task_note"], "elapsed_seconds": 0, "remaining_seconds": 0, "session_id": None, + "cycle": cycle_dict, } elapsed = self._elapsed(session) total = session.duration_min * 60 remaining = max(0, total - elapsed) - if session.status == "running" and remaining == 0: - session.status = "completed" - session.finished_at = _utcnow() - session.completed = True - self.db.commit() - self.db.refresh(session) - return { "status": session.status, + "phase": session.phase, "duration_min": session.duration_min, "task_note": session.task_note, "elapsed_seconds": elapsed, @@ -68,27 +140,35 @@ class PomodoroService: "session_id": session.id, "started_at": session.started_at.isoformat() if session.started_at else None, "finished_at": session.finished_at.isoformat() if session.finished_at else None, + "cycle": cycle_dict, } def get_status(self) -> dict: - return self._to_status_dict(self._get_active()) - - def start(self, duration_min: int = 25, task_note: str = "") -> dict: active = self._get_active() if active: - raise ValueError("Таймер уже запущен. Сначала остановите текущую сессию.") + self._try_auto_complete(active) + active = self._get_active() + return self._to_status_dict(active) - session = PomodoroSession( - status="running", + def start_work(self, duration_min: int | None = None, task_note: str = "") -> dict: + session = self._start_phase( + PHASE_WORK, duration_min=duration_min, task_note=task_note, - started_at=_utcnow(), ) - self.db.add(session) - self.db.commit() - self.db.refresh(session) return self._to_status_dict(session) + def start_short_break(self, duration_min: int | None = None) -> dict: + session = self._start_phase(PHASE_SHORT_BREAK, duration_min=duration_min) + return self._to_status_dict(session) + + def start_long_break(self, duration_min: int | None = None) -> dict: + session = self._start_phase(PHASE_LONG_BREAK, duration_min=duration_min) + return self._to_status_dict(session) + + def start(self, duration_min: int = 25, task_note: str = "") -> dict: + return self.start_work(duration_min=duration_min, task_note=task_note) + def pause(self) -> dict: session = self._get_active() if not session or session.status != "running": @@ -119,15 +199,69 @@ class PomodoroService: if not session: raise ValueError("Нет активного таймера.") - session.elapsed_seconds = self._elapsed(session) - session.status = "completed" if completed else "cancelled" - session.result = result - session.completed = completed - session.finished_at = _utcnow() - session.started_at = None + if completed: + self._finalize_session(session, auto=False, result=result, completed=True) + else: + self._finalize_session(session, auto=False, result=result, cancelled=True) + session.completion_notified = True + self.db.commit() + return self._to_status_dict(None) + + def reset_cycle(self, clear_task: bool = False) -> dict: + active = self._get_active() + if active: + self._finalize_session(active, auto=False, cancelled=True) + active.completion_notified = True + self.db.commit() + cycle = self.cycle.reset(clear_task=clear_task) + status = self._to_status_dict(None) + status["cycle"] = cycle + return status + + def skip_phase(self) -> dict: + session = self._get_active() + if not session: + raise ValueError("Нет активного таймера.") + + self._finalize_session(session, auto=True) + return self._to_status_dict(None) + + def get_pending_completions(self) -> list[PomodoroSession]: + stmt = ( + select(PomodoroSession) + .where( + PomodoroSession.status == "completed", + PomodoroSession.completed.is_(True), + PomodoroSession.completion_notified.is_(False), + ) + .order_by(PomodoroSession.id.asc()) + ) + return list(self.db.scalars(stmt)) + + def mark_notified(self, session: PomodoroSession) -> None: + session.completion_notified = True self.db.commit() - self.db.refresh(session) - return self._to_status_dict(session) + + def advance_after_completion(self, session: PomodoroSession) -> dict | None: + """Update cycle counters and auto-start next phase. Returns new status or None.""" + phase = session.phase + cycle = self.cycle.get() + + if phase == PHASE_WORK: + next_phase = self.cycle.on_work_completed() + elif phase == PHASE_SHORT_BREAK: + next_phase = PHASE_WORK + elif phase == PHASE_LONG_BREAK: + self.cycle.on_long_break_completed() + next_phase = None + else: + next_phase = None + + if not cycle.auto_advance or next_phase is None: + return None + + new_session = self._start_phase(next_phase) + return self._to_status_dict(new_session) def history(self, limit: int = 20) -> list[dict]: stmt = ( @@ -141,6 +275,7 @@ class PomodoroService: { "id": s.id, "status": s.status, + "phase": s.phase, "duration_min": s.duration_min, "task_note": s.task_note, "result": s.result, diff --git a/backend/app/pomodoro/watcher.py b/backend/app/pomodoro/watcher.py new file mode 100644 index 0000000..6094a8c --- /dev/null +++ b/backend/app/pomodoro/watcher.py @@ -0,0 +1,38 @@ +import asyncio +import logging + +from app.db.base import SessionLocal +from app.pomodoro.completion import PomodoroCompletionHandler +from app.pomodoro.service import PomodoroService + +logger = logging.getLogger(__name__) + +WATCH_INTERVAL_SEC = 2 + + +async def pomodoro_watcher_loop() -> None: + while True: + try: + await asyncio.sleep(WATCH_INTERVAL_SEC) + await _tick() + except asyncio.CancelledError: + raise + except Exception: + logger.exception("Pomodoro watcher error") + + +async def _tick() -> None: + db = SessionLocal() + try: + service = PomodoroService(db) + service.get_status() + + pending = service.get_pending_completions() + if not pending: + return + + handler = PomodoroCompletionHandler(db) + for session in pending: + await handler.process(session) + finally: + db.close() diff --git a/backend/app/tools/registry.py b/backend/app/tools/registry.py index 8b097a4..608dc2f 100644 --- a/backend/app/tools/registry.py +++ b/backend/app/tools/registry.py @@ -10,7 +10,7 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [ "type": "function", "function": { "name": "get_pomodoro_status", - "description": "ОБЯЗАТЕЛЬНО вызывай перед любым ответом о таймере. Возвращает актуальный статус помидоро.", + "description": "ОБЯЗАТЕЛЬНО вызывай перед любым ответом о таймере. Статус, фаза и прогресс цикла.", "parameters": {"type": "object", "properties": {}, "required": []}, }, }, @@ -18,18 +18,40 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [ "type": "function", "function": { "name": "start_pomodoro", - "description": "Запустить помидоро-таймер. Вызывай при каждой просьбе поставить таймер — не полагайся на память.", + "description": "Запустить фазу работы в цикле помидоро (25 мин по умолчанию).", "parameters": { "type": "object", "properties": { - "duration_min": { - "type": "integer", - "description": "Длительность в минутах, по умолчанию 25", - }, - "task_note": { - "type": "string", - "description": "Над чем работаем в этой сессии", - }, + "duration_min": {"type": "integer", "description": "Минуты работы"}, + "task_note": {"type": "string", "description": "Над чем работаем"}, + }, + "required": [], + }, + }, + }, + { + "type": "function", + "function": { + "name": "start_short_break", + "description": "Запустить короткий перерыв между работами.", + "parameters": { + "type": "object", + "properties": { + "duration_min": {"type": "integer", "description": "Минуты перерыва"}, + }, + "required": [], + }, + }, + }, + { + "type": "function", + "function": { + "name": "start_long_break", + "description": "Запустить длинный перерыв после завершения цикла работ.", + "parameters": { + "type": "object", + "properties": { + "duration_min": {"type": "integer", "description": "Минуты перерыва"}, }, "required": [], }, @@ -39,17 +61,39 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [ "type": "function", "function": { "name": "stop_pomodoro", - "description": "Остановить текущий помидоро-таймер", + "description": "Остановить текущую фазу таймера.", "parameters": { "type": "object", "properties": { - "result": { - "type": "string", - "description": "Краткий отчёт о том, что сделано", - }, + "result": {"type": "string", "description": "Отчёт о сделанном"}, "completed": { "type": "boolean", - "description": "True если задача полностью завершена", + "description": "True если фаза полностью завершена", + }, + }, + "required": [], + }, + }, + }, + { + "type": "function", + "function": { + "name": "skip_pomodoro_phase", + "description": "Досрочно завершить текущую фазу и перейти к следующей в цикле.", + "parameters": {"type": "object", "properties": {}, "required": []}, + }, + }, + { + "type": "function", + "function": { + "name": "reset_pomodoro_cycle", + "description": "Сбросить цикл помидоро: обнулить счётчик работ и остановить таймер.", + "parameters": { + "type": "object", + "properties": { + "clear_task": { + "type": "boolean", + "description": "Также очистить текущую задачу", }, }, "required": [], @@ -60,14 +104,11 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [ "type": "function", "function": { "name": "get_pomodoro_history", - "description": "ОБЯЗАТЕЛЬНО вызывай при вопросах о задачах, истории работы или что пользователь делал.", + "description": "ОБЯЗАТЕЛЬНО при вопросах о задачах, истории работы или что пользователь делал.", "parameters": { "type": "object", "properties": { - "limit": { - "type": "integer", - "description": "Сколько последних сессий вернуть, по умолчанию 10", - } + "limit": {"type": "integer", "description": "Сколько сессий вернуть"}, }, "required": [], }, @@ -83,15 +124,29 @@ def execute_tool(db: Session, name: str, arguments: dict[str, Any]) -> str: if name == "get_pomodoro_status": result = service.get_status() elif name == "start_pomodoro": - result = service.start( - duration_min=arguments.get("duration_min", 25), + result = service.start_work( + duration_min=arguments.get("duration_min"), task_note=arguments.get("task_note", ""), ) + elif name == "start_short_break": + result = service.start_short_break( + duration_min=arguments.get("duration_min"), + ) + elif name == "start_long_break": + result = service.start_long_break( + duration_min=arguments.get("duration_min"), + ) elif name == "stop_pomodoro": result = service.stop( result=arguments.get("result", ""), completed=arguments.get("completed", False), ) + elif name == "skip_pomodoro_phase": + result = service.skip_phase() + elif name == "reset_pomodoro_cycle": + result = service.reset_cycle( + clear_task=arguments.get("clear_task", False), + ) elif name == "get_pomodoro_history": result = service.history(limit=arguments.get("limit", 10)) else: diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index e0ec59b..6353da6 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -18,8 +18,20 @@ export interface SessionDetail extends ChatSession { messages: ChatMessage[]; } +export interface PomodoroCycle { + completed_work_sessions: number; + sessions_until_long_break: number; + task_note: string; + work_duration_min: number; + short_break_min: number; + long_break_min: number; + auto_advance: boolean; + chat_notify_seq: number; +} + export interface PomodoroStatus { status: string; + phase: string; duration_min: number; task_note: string; elapsed_seconds: number; @@ -27,6 +39,7 @@ export interface PomodoroStatus { session_id: number | null; started_at?: string | null; finished_at?: string | null; + cycle: PomodoroCycle; } export interface CharacterCardData { @@ -54,6 +67,7 @@ export interface CharacterCardV2 { export interface PomodoroHistoryItem { id: number; status: string; + phase: string; duration_min: number; task_note: string; result: string | null; @@ -152,6 +166,26 @@ export const api = { pomodoroHistory: () => request("/api/v1/pomodoro/history"), + pomodoroResetCycle: (clear_task = false) => + request(`/api/v1/pomodoro/cycle/reset?clear_task=${clear_task}`, { + method: "POST", + }), + + pomodoroSkip: () => + request("/api/v1/pomodoro/skip", { method: "POST" }), + + pomodoroStartShortBreak: (duration_min?: number) => + request( + `/api/v1/pomodoro/break/short/start${duration_min ? `?duration_min=${duration_min}` : ""}`, + { method: "POST" } + ), + + pomodoroStartLongBreak: (duration_min?: number) => + request( + `/api/v1/pomodoro/break/long/start${duration_min ? `?duration_min=${duration_min}` : ""}`, + { method: "POST" } + ), + getCharacter: () => request("/api/v1/character"), saveCharacter: (card: CharacterCardV2) => diff --git a/frontend/src/components/PomodoroWidget.css b/frontend/src/components/PomodoroWidget.css index e1fc545..f2b27d6 100644 --- a/frontend/src/components/PomodoroWidget.css +++ b/frontend/src/components/PomodoroWidget.css @@ -63,8 +63,15 @@ display: none; } +.pomodoro-widget-cycle { + margin: 0.45rem 0 0; + font-size: 0.75rem; + color: #8b95a5; + text-align: center; +} + .pomodoro-widget-task { - margin: 0.5rem 0 0; + margin: 0.25rem 0 0; font-size: 0.8rem; color: #a8b0bd; text-align: center; diff --git a/frontend/src/components/PomodoroWidget.tsx b/frontend/src/components/PomodoroWidget.tsx index de4773b..697c1a2 100644 --- a/frontend/src/components/PomodoroWidget.tsx +++ b/frontend/src/components/PomodoroWidget.tsx @@ -1,6 +1,7 @@ import { Link } from "react-router-dom"; import { usePomodoro } from "../hooks/usePomodoro"; import { formatTime } from "../utils/time"; +import { phaseLabel } from "../utils/pomodoro"; import "./PomodoroWidget.css"; interface PomodoroWidgetProps { @@ -17,22 +18,31 @@ export default function PomodoroWidget({ compact = false }: PomodoroWidgetProps) const progress = isActive ? ((status.duration_min * 60 - status.remaining_seconds) / (status.duration_min * 60)) * 100 : 0; + const cycle = status.cycle; + const ringColor = status.phase === "work" ? "#4f7cff" : "#3dbf8f"; return (
{formatTime(displaySeconds)} - {status.status === "idle" ? "помидоро" : status.status} + {isActive ? phaseLabel(status.phase) : "помидоро"}
- {!compact && status.task_note && ( -

{status.task_note}

+ {!compact && ( + <> + {cycle && ( +

+ Цикл {cycle.completed_work_sessions}/{cycle.sessions_until_long_break} +

+ )} + {status.task_note &&

{status.task_note}

} + )} ); diff --git a/frontend/src/pages/Chat.tsx b/frontend/src/pages/Chat.tsx index 1401bc2..44e76c6 100644 --- a/frontend/src/pages/Chat.tsx +++ b/frontend/src/pages/Chat.tsx @@ -26,7 +26,8 @@ export default function Chat() { const [streaming, setStreaming] = useState(""); const [liveNotices, setLiveNotices] = useState([]); const bottomRef = useRef(null); - const { refresh: refreshPomodoro } = usePomodoro(); + const { status: pomodoroStatus, refresh: refreshPomodoro } = usePomodoro(); + const [lastNotifySeq, setLastNotifySeq] = useState(0); const loadSessions = async () => { const data = await api.listSessions(); @@ -55,6 +56,14 @@ export default function Chat() { bottomRef.current?.scrollIntoView({ behavior: "smooth" }); }, [messages, streaming, liveNotices]); + useEffect(() => { + const seq = pomodoroStatus?.cycle?.chat_notify_seq ?? 0; + if (seq > lastNotifySeq && activeId) { + setLastNotifySeq(seq); + loadMessages(activeId).catch(console.error); + } + }, [pomodoroStatus?.cycle?.chat_notify_seq, activeId, lastNotifySeq]); + const handleNewChat = async () => { const session = await api.createSession(); await loadSessions(); diff --git a/frontend/src/pages/Pomodoro.css b/frontend/src/pages/Pomodoro.css index 3a6de69..8069dfc 100644 --- a/frontend/src/pages/Pomodoro.css +++ b/frontend/src/pages/Pomodoro.css @@ -51,14 +51,40 @@ color: #c5ccd6; } +.cycle-badge { + text-align: center; + color: #8b95a5; + font-size: 0.85rem; + margin-bottom: 0.75rem; +} + .timer-form, .timer-controls, -.stop-form { +.stop-form, +.timer-form-actions { display: flex; flex-direction: column; gap: 0.75rem; } +.timer-form-actions button { + background: #2b3445; + color: inherit; + border: 1px solid #3a4558; + border-radius: 8px; + padding: 0.55rem 0.85rem; +} + +.reset-cycle-btn { + margin-top: 1rem; + background: transparent; + border: 1px solid #4a3540; + color: #c58a8a; + border-radius: 8px; + padding: 0.5rem 0.85rem; + width: 100%; +} + .timer-form label, .stop-form label { display: flex; diff --git a/frontend/src/pages/Pomodoro.tsx b/frontend/src/pages/Pomodoro.tsx index c114eee..bb5869f 100644 --- a/frontend/src/pages/Pomodoro.tsx +++ b/frontend/src/pages/Pomodoro.tsx @@ -1,13 +1,9 @@ import { FormEvent, useEffect, useState } from "react"; import { api, PomodoroHistoryItem, PomodoroStatus } from "../api/client"; +import { phaseLabel } from "../utils/pomodoro"; +import { formatTime } from "../utils/time"; import "./Pomodoro.css"; -function formatTime(seconds: number): string { - const m = Math.floor(seconds / 60); - const s = seconds % 60; - return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`; -} - export default function Pomodoro() { const [status, setStatus] = useState(null); const [history, setHistory] = useState([]); @@ -21,6 +17,12 @@ export default function Pomodoro() { const [current, past] = await Promise.all([api.pomodoroStatus(), api.pomodoroHistory()]); setStatus(current); setHistory(past); + if (current.cycle?.work_duration_min) { + setDuration(current.cycle.work_duration_min); + } + if (current.cycle?.task_note) { + setTaskNote(current.cycle.task_note); + } }; useEffect(() => { @@ -31,7 +33,7 @@ export default function Pomodoro() { return () => clearInterval(timer); }, []); - const handleStart = async (e: FormEvent) => { + const handleStartWork = async (e: FormEvent) => { e.preventDefault(); setError(""); try { @@ -67,7 +69,6 @@ export default function Pomodoro() { try { await api.pomodoroStop(result, completed); await refresh(); - setTaskNote(""); setResult(""); setCompleted(false); } catch (err) { @@ -75,28 +76,62 @@ export default function Pomodoro() { } }; + const handleSkip = async () => { + setError(""); + try { + await api.pomodoroSkip(); + await refresh(); + } catch (err) { + setError(err instanceof Error ? err.message : "Ошибка"); + } + }; + + const handleReset = async () => { + setError(""); + try { + await api.pomodoroResetCycle(false); + await refresh(); + } catch (err) { + setError(err instanceof Error ? err.message : "Ошибка"); + } + }; + const isActive = status?.status === "running" || status?.status === "paused"; const displaySeconds = isActive ? (status?.remaining_seconds ?? 0) : duration * 60; - const progress = status + const progress = status && isActive ? ((status.duration_min * 60 - status.remaining_seconds) / (status.duration_min * 60)) * 100 : 0; + const cycle = status?.cycle; + const ringColor = status?.phase === "work" ? "#4f7cff" : "#3dbf8f"; return (
-
+ {cycle && ( +
+ Цикл {cycle.completed_work_sessions}/{cycle.sessions_until_long_break} + {cycle.auto_advance && " · авто"} +
+ )} + +
{formatTime(displaySeconds)}
-
{status?.status ?? "idle"}
+
+ {isActive ? phaseLabel(status?.phase ?? "work") : status?.status ?? "idle"} +
{status?.task_note &&

Задача: {status.task_note}

} {!isActive ? ( -
+ - +
+ + + +
) : (
@@ -124,6 +167,7 @@ export default function Pomodoro() { ) : ( )} +
setCompleted(e.target.checked)} /> - Задача завершена + Фаза завершена
)} + + {error &&

{error}

}
@@ -152,10 +200,11 @@ export default function Pomodoro() { {history.map((item) => (
  • - {item.task_note || "Без описания"} — {item.status} + {phaseLabel(item.phase)}: {item.task_note || "Без описания"} — {item.status}
    - {item.duration_min} мин · {item.finished_at ? new Date(item.finished_at).toLocaleString("ru-RU") : ""} + {item.duration_min} мин ·{" "} + {item.finished_at ? new Date(item.finished_at).toLocaleString("ru-RU") : ""}
    {item.result &&
    {item.result}
    }
  • diff --git a/frontend/src/utils/pomodoro.ts b/frontend/src/utils/pomodoro.ts new file mode 100644 index 0000000..b90b99f --- /dev/null +++ b/frontend/src/utils/pomodoro.ts @@ -0,0 +1,9 @@ +export const PHASE_LABELS: Record = { + work: "Работа", + short_break: "Перерыв", + long_break: "Длинный перерыв", +}; + +export function phaseLabel(phase: string): string { + return PHASE_LABELS[phase] ?? phase; +} diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo index 5b8bc19..91a488e 100644 --- a/frontend/tsconfig.tsbuildinfo +++ b/frontend/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/components/pomodorowidget.tsx","./src/hooks/usepomodoro.ts","./src/pages/character.tsx","./src/pages/chat.tsx","./src/pages/pomodoro.tsx","./src/utils/charactercard.ts","./src/utils/time.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/components/pomodorowidget.tsx","./src/hooks/usepomodoro.ts","./src/pages/character.tsx","./src/pages/chat.tsx","./src/pages/pomodoro.tsx","./src/utils/charactercard.ts","./src/utils/pomodoro.ts","./src/utils/time.ts"],"version":"5.9.3"} \ No newline at end of file